@stigmer/react 3.0.8-dev.20260613051837 → 3.0.8-dev.20260613072749
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 +7 -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 +7 -0
- package/composer/icons.d.ts.map +1 -1
- package/composer/icons.js +9 -0
- package/composer/icons.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 +16 -3
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +15 -9
- 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/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 +2 -1
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +3 -2
- package/internal/VirtualizedThread.js.map +1 -1
- package/package.json +4 -4
- package/session/SessionViewer.js +17 -2
- package/session/SessionViewer.js.map +1 -1
- package/session/useSessionConversation.d.ts +21 -0
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +22 -0
- package/session/useSessionConversation.js.map +1 -1
- package/src/composer/ComposerToolbar.tsx +35 -9
- package/src/composer/SessionComposer.tsx +22 -1
- package/src/composer/__tests__/SessionComposer-stop.test.tsx +98 -0
- package/src/composer/icons.tsx +20 -0
- package/src/execution/MessageEntry.tsx +57 -2
- package/src/execution/MessageThread.tsx +32 -3
- package/src/execution/__tests__/MessageThread.test.tsx +66 -0
- package/src/execution/__tests__/useAgentExecutionActions.test.tsx +299 -0
- package/src/execution/index.ts +6 -0
- package/src/execution/useAgentExecutionActions.ts +205 -0
- package/src/index.ts +3 -0
- package/src/internal/VirtualizedThread.tsx +4 -1
- package/src/session/SessionViewer.tsx +25 -1
- package/src/session/__tests__/useSessionConversation.test.tsx +92 -0
- package/src/session/useSessionConversation.ts +50 -0
- package/styles.css +1 -1
|
@@ -14,6 +14,13 @@ export interface MessageEntryProps {
|
|
|
14
14
|
readonly message: AgentMessage;
|
|
15
15
|
/** Additional CSS class names for the root container. */
|
|
16
16
|
readonly className?: string;
|
|
17
|
+
/**
|
|
18
|
+
* When provided on a `MESSAGE_HUMAN` entry, a hover-revealed "Edit" button
|
|
19
|
+
* appears on the bubble. Clicking it invokes this callback — the session
|
|
20
|
+
* chat uses it to stop the in-flight turn and pre-fill the composer with
|
|
21
|
+
* this message for editing. Ignored for non-human messages.
|
|
22
|
+
*/
|
|
23
|
+
readonly onEdit?: () => void;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
/**
|
|
@@ -43,6 +50,7 @@ export interface MessageEntryProps {
|
|
|
43
50
|
export const MessageEntry = memo(function MessageEntry({
|
|
44
51
|
message,
|
|
45
52
|
className,
|
|
53
|
+
onEdit,
|
|
46
54
|
}: MessageEntryProps) {
|
|
47
55
|
useRenderTracer("MessageEntry", {
|
|
48
56
|
messageType: message.type,
|
|
@@ -52,7 +60,13 @@ export const MessageEntry = memo(function MessageEntry({
|
|
|
52
60
|
|
|
53
61
|
switch (message.type) {
|
|
54
62
|
case MessageType.MESSAGE_HUMAN:
|
|
55
|
-
return
|
|
63
|
+
return (
|
|
64
|
+
<HumanMessage
|
|
65
|
+
content={message.content}
|
|
66
|
+
className={className}
|
|
67
|
+
onEdit={onEdit}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
56
70
|
case MessageType.MESSAGE_AI:
|
|
57
71
|
return (
|
|
58
72
|
<AiMessage
|
|
@@ -79,21 +93,62 @@ export const MessageEntry = memo(function MessageEntry({
|
|
|
79
93
|
function HumanMessage({
|
|
80
94
|
content,
|
|
81
95
|
className,
|
|
96
|
+
onEdit,
|
|
82
97
|
}: {
|
|
83
98
|
content: string;
|
|
84
99
|
className?: string;
|
|
100
|
+
onEdit?: () => void;
|
|
85
101
|
}) {
|
|
86
102
|
return (
|
|
87
103
|
<div
|
|
88
104
|
role="article"
|
|
89
105
|
aria-label="User message"
|
|
90
|
-
className={cn(
|
|
106
|
+
className={cn(
|
|
107
|
+
"group relative ms-[20%] rounded-lg bg-muted-subtle px-4 py-3",
|
|
108
|
+
className,
|
|
109
|
+
)}
|
|
91
110
|
>
|
|
92
111
|
<p className="text-sm text-foreground whitespace-pre-wrap">{content}</p>
|
|
112
|
+
{onEdit && (
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={onEdit}
|
|
116
|
+
aria-label="Edit message"
|
|
117
|
+
title="Edit"
|
|
118
|
+
className={cn(
|
|
119
|
+
"absolute -top-2.5 -right-2.5 inline-flex h-7 w-7 items-center justify-center rounded-full",
|
|
120
|
+
"border border-border bg-card text-muted-foreground shadow-sm transition",
|
|
121
|
+
"hover:text-foreground hover:bg-accent-hover",
|
|
122
|
+
"opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
|
|
123
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
<EditIcon />
|
|
127
|
+
</button>
|
|
128
|
+
)}
|
|
93
129
|
</div>
|
|
94
130
|
);
|
|
95
131
|
}
|
|
96
132
|
|
|
133
|
+
function EditIcon() {
|
|
134
|
+
return (
|
|
135
|
+
<svg
|
|
136
|
+
width="13"
|
|
137
|
+
height="13"
|
|
138
|
+
viewBox="0 0 24 24"
|
|
139
|
+
fill="none"
|
|
140
|
+
stroke="currentColor"
|
|
141
|
+
strokeWidth="2"
|
|
142
|
+
strokeLinecap="round"
|
|
143
|
+
strokeLinejoin="round"
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
>
|
|
146
|
+
<path d="M12 20h9" />
|
|
147
|
+
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
|
148
|
+
</svg>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
97
152
|
function AiMessage({
|
|
98
153
|
content,
|
|
99
154
|
isStreaming,
|
|
@@ -74,6 +74,17 @@ export interface MessageThreadProps {
|
|
|
74
74
|
* "Retry" control when {@link pendingMessageFailed} is `true`.
|
|
75
75
|
*/
|
|
76
76
|
readonly onRetrySend?: () => void;
|
|
77
|
+
/**
|
|
78
|
+
* When provided, the in-flight human turn (the active execution's prompt)
|
|
79
|
+
* shows a hover "Edit" affordance. Clicking it invokes this callback with
|
|
80
|
+
* the message text — the session chat stops the running execution and
|
|
81
|
+
* pre-fills the composer for an edit-and-resubmit.
|
|
82
|
+
*
|
|
83
|
+
* Provide this only while the active execution is stoppable; the SDK marks
|
|
84
|
+
* exactly the active-stream human turn editable. When omitted, no edit
|
|
85
|
+
* control is shown (backward compatible).
|
|
86
|
+
*/
|
|
87
|
+
readonly onEditMessage?: (text: string) => void;
|
|
77
88
|
/**
|
|
78
89
|
* Retry handler for a terminal-failed execution that exposed a server
|
|
79
90
|
* error reason. Receives the originating message; the consumer typically
|
|
@@ -199,7 +210,7 @@ export interface MessageThreadProps {
|
|
|
199
210
|
* part of the public API.
|
|
200
211
|
*/
|
|
201
212
|
export type ThreadItem =
|
|
202
|
-
| { readonly kind: "message"; readonly message: AgentMessage; readonly key: string; readonly isPending?: boolean; readonly isFailed?: boolean }
|
|
213
|
+
| { readonly kind: "message"; readonly message: AgentMessage; readonly key: string; readonly isPending?: boolean; readonly isFailed?: boolean; readonly isEditable?: boolean }
|
|
203
214
|
| { readonly kind: "tool-group"; readonly toolCalls: readonly ToolCall[]; readonly subAgentExecutions: readonly SubAgentExecution[]; readonly key: string }
|
|
204
215
|
| { readonly kind: "sub-agent"; readonly subAgentExecution: SubAgentExecution; readonly key: string }
|
|
205
216
|
| { readonly kind: "phase-badge"; readonly phase: ExecutionPhase; readonly key: string }
|
|
@@ -239,6 +250,7 @@ export function buildThreadItems(
|
|
|
239
250
|
workspaceEntries: readonly WorkspaceEntry[] | undefined,
|
|
240
251
|
summarizationEvents?: readonly SummarizationEventView[],
|
|
241
252
|
pendingMessageFailed = false,
|
|
253
|
+
editableActiveTurn = false,
|
|
242
254
|
): ThreadItem[] {
|
|
243
255
|
const items: ThreadItem[] = [];
|
|
244
256
|
const allExecutions = activeStreamExecution
|
|
@@ -297,6 +309,9 @@ export function buildThreadItems(
|
|
|
297
309
|
kind: "message",
|
|
298
310
|
message: syntheticHumanMsg,
|
|
299
311
|
key: bridgePending ? "pending-user-turn" : `${execId}-spec`,
|
|
312
|
+
// The active execution's prompt is the one a user can edit-and-resubmit
|
|
313
|
+
// (stop + rephrase). Only mark it when the consumer enabled editing.
|
|
314
|
+
isEditable: isActiveStreamExec && editableActiveTurn,
|
|
300
315
|
});
|
|
301
316
|
}
|
|
302
317
|
|
|
@@ -510,6 +525,7 @@ export function MessageThread({
|
|
|
510
525
|
pendingMessageFailed = false,
|
|
511
526
|
onRetrySend,
|
|
512
527
|
onRetryExecution,
|
|
528
|
+
onEditMessage,
|
|
513
529
|
className,
|
|
514
530
|
formatToolCallSummary,
|
|
515
531
|
onApprovalSubmit,
|
|
@@ -527,9 +543,10 @@ export function MessageThread({
|
|
|
527
543
|
useRenderTracer("MessageThread", { executions, activeStreamExecution });
|
|
528
544
|
|
|
529
545
|
const includeApprovals = onApprovalSubmit != null;
|
|
546
|
+
const editableActiveTurn = onEditMessage != null;
|
|
530
547
|
const items = useMemo(
|
|
531
|
-
() => buildThreadItems(executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed),
|
|
532
|
-
[executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed],
|
|
548
|
+
() => buildThreadItems(executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed, editableActiveTurn),
|
|
549
|
+
[executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed, editableActiveTurn],
|
|
533
550
|
);
|
|
534
551
|
|
|
535
552
|
useKeyStability(items);
|
|
@@ -564,6 +581,7 @@ export function MessageThread({
|
|
|
564
581
|
centerContent={centerContent}
|
|
565
582
|
onRetrySend={onRetrySend}
|
|
566
583
|
onRetryExecution={onRetryExecution}
|
|
584
|
+
onEditMessage={onEditMessage}
|
|
567
585
|
/>
|
|
568
586
|
</Suspense>
|
|
569
587
|
</div>
|
|
@@ -585,6 +603,7 @@ export function MessageThread({
|
|
|
585
603
|
planActionsDisabled={planActionsDisabled}
|
|
586
604
|
onRetrySend={onRetrySend}
|
|
587
605
|
onRetryExecution={onRetryExecution}
|
|
606
|
+
onEditMessage={onEditMessage}
|
|
588
607
|
/>
|
|
589
608
|
);
|
|
590
609
|
}
|
|
@@ -611,6 +630,7 @@ interface NonVirtualizedThreadProps {
|
|
|
611
630
|
readonly planActionsDisabled?: boolean;
|
|
612
631
|
readonly onRetrySend?: () => void;
|
|
613
632
|
readonly onRetryExecution?: (message: string) => void;
|
|
633
|
+
readonly onEditMessage?: (text: string) => void;
|
|
614
634
|
}
|
|
615
635
|
|
|
616
636
|
function NonVirtualizedThread({
|
|
@@ -627,6 +647,7 @@ function NonVirtualizedThread({
|
|
|
627
647
|
planActionsDisabled,
|
|
628
648
|
onRetrySend,
|
|
629
649
|
onRetryExecution,
|
|
650
|
+
onEditMessage,
|
|
630
651
|
}: NonVirtualizedThreadProps) {
|
|
631
652
|
const { scrollRef, sentinelRef, contentRef, isFollowing, jumpToLatest } =
|
|
632
653
|
useAutoScroll();
|
|
@@ -664,6 +685,7 @@ function NonVirtualizedThread({
|
|
|
664
685
|
planActionsDisabled={planActionsDisabled}
|
|
665
686
|
onRetrySend={onRetrySend}
|
|
666
687
|
onRetryExecution={onRetryExecution}
|
|
688
|
+
onEditMessage={onEditMessage}
|
|
667
689
|
/>
|
|
668
690
|
</ThreadItemWrapper>
|
|
669
691
|
))}
|
|
@@ -702,6 +724,7 @@ export interface ThreadItemRendererProps {
|
|
|
702
724
|
readonly planActionsDisabled?: boolean;
|
|
703
725
|
readonly onRetrySend?: () => void;
|
|
704
726
|
readonly onRetryExecution?: (message: string) => void;
|
|
727
|
+
readonly onEditMessage?: (text: string) => void;
|
|
705
728
|
}
|
|
706
729
|
|
|
707
730
|
/**
|
|
@@ -725,6 +748,7 @@ export function ThreadItemRenderer({
|
|
|
725
748
|
planActionsDisabled,
|
|
726
749
|
onRetrySend,
|
|
727
750
|
onRetryExecution,
|
|
751
|
+
onEditMessage,
|
|
728
752
|
}: ThreadItemRendererProps) {
|
|
729
753
|
switch (item.kind) {
|
|
730
754
|
case "message":
|
|
@@ -737,6 +761,11 @@ export function ThreadItemRenderer({
|
|
|
737
761
|
<MessageEntry
|
|
738
762
|
message={item.message}
|
|
739
763
|
className={item.isPending ? "opacity-70" : undefined}
|
|
764
|
+
onEdit={
|
|
765
|
+
item.isEditable && onEditMessage
|
|
766
|
+
? () => onEditMessage(item.message.content)
|
|
767
|
+
: undefined
|
|
768
|
+
}
|
|
740
769
|
/>
|
|
741
770
|
);
|
|
742
771
|
case "tool-group":
|
|
@@ -310,6 +310,72 @@ describe("MessageThread", () => {
|
|
|
310
310
|
expect(onRetrySend).toHaveBeenCalledOnce();
|
|
311
311
|
});
|
|
312
312
|
|
|
313
|
+
it("shows an Edit affordance on the in-flight human turn and routes onEditMessage", () => {
|
|
314
|
+
const active = makeExecution({
|
|
315
|
+
id: "exec-active",
|
|
316
|
+
specMessage: "fix the bug",
|
|
317
|
+
phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
|
|
318
|
+
aiContent: "working on it",
|
|
319
|
+
});
|
|
320
|
+
const onEditMessage = vi.fn();
|
|
321
|
+
|
|
322
|
+
render(
|
|
323
|
+
<MessageThread
|
|
324
|
+
executions={[]}
|
|
325
|
+
activeStreamExecution={active}
|
|
326
|
+
onEditMessage={onEditMessage}
|
|
327
|
+
/>,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const editBtn = screen.getByRole("button", { name: "Edit message" });
|
|
331
|
+
fireEvent.click(editBtn);
|
|
332
|
+
expect(onEditMessage).toHaveBeenCalledWith("fix the bug");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("shows no Edit affordance when onEditMessage is omitted", () => {
|
|
336
|
+
const active = makeExecution({
|
|
337
|
+
id: "exec-active",
|
|
338
|
+
specMessage: "fix the bug",
|
|
339
|
+
phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
render(
|
|
343
|
+
<MessageThread executions={[]} activeStreamExecution={active} />,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
expect(
|
|
347
|
+
screen.queryByRole("button", { name: "Edit message" }),
|
|
348
|
+
).toBeNull();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("marks only the active-stream human turn editable, not completed turns", () => {
|
|
352
|
+
const completed = makeExecution({
|
|
353
|
+
id: "exec-done",
|
|
354
|
+
specMessage: "old turn",
|
|
355
|
+
phase: ExecutionPhase.EXECUTION_COMPLETED,
|
|
356
|
+
});
|
|
357
|
+
const active = makeExecution({
|
|
358
|
+
id: "exec-active",
|
|
359
|
+
specMessage: "new turn",
|
|
360
|
+
phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
|
|
361
|
+
});
|
|
362
|
+
const onEditMessage = vi.fn();
|
|
363
|
+
|
|
364
|
+
render(
|
|
365
|
+
<MessageThread
|
|
366
|
+
executions={[completed]}
|
|
367
|
+
activeStreamExecution={active}
|
|
368
|
+
onEditMessage={onEditMessage}
|
|
369
|
+
/>,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const editBtns = screen.getAllByRole("button", { name: "Edit message" });
|
|
373
|
+
expect(editBtns).toHaveLength(1);
|
|
374
|
+
|
|
375
|
+
fireEvent.click(editBtns[0]);
|
|
376
|
+
expect(onEditMessage).toHaveBeenCalledWith("new turn");
|
|
377
|
+
});
|
|
378
|
+
|
|
313
379
|
it("renders plan-completion card when last execution is completed Plan mode", () => {
|
|
314
380
|
const exec = makeExecution({
|
|
315
381
|
id: "exec-plan",
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { renderHook, act } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import type { Stigmer } from "@stigmer/sdk";
|
|
5
|
+
import { StigmerContext } from "../../context";
|
|
6
|
+
import { useAgentExecutionActions } from "../useAgentExecutionActions";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
function makeExecution(id = "aex-001", phase = 2 /* IN_PROGRESS */) {
|
|
13
|
+
return {
|
|
14
|
+
metadata: { id, name: "test-execution", org: "org-1" },
|
|
15
|
+
status: { phase },
|
|
16
|
+
} as any;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const mockCancel = vi.fn();
|
|
20
|
+
const mockTerminate = vi.fn();
|
|
21
|
+
const mockPause = vi.fn();
|
|
22
|
+
const mockResume = vi.fn();
|
|
23
|
+
|
|
24
|
+
function makeMockClient(): Stigmer {
|
|
25
|
+
return {
|
|
26
|
+
agentExecution: {
|
|
27
|
+
cancel: mockCancel,
|
|
28
|
+
terminate: mockTerminate,
|
|
29
|
+
pause: mockPause,
|
|
30
|
+
resume: mockResume,
|
|
31
|
+
},
|
|
32
|
+
} as unknown as Stigmer;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createWrapper(client: Stigmer) {
|
|
36
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
37
|
+
return (
|
|
38
|
+
<StigmerContext.Provider value={client}>
|
|
39
|
+
{children}
|
|
40
|
+
</StigmerContext.Provider>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Lifecycle actions (cancel, terminate, pause, resume)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
describe("useAgentExecutionActions", () => {
|
|
54
|
+
it("cancel returns updated execution on success", async () => {
|
|
55
|
+
const execution = makeExecution("aex-001", 5 /* CANCELLED */);
|
|
56
|
+
mockCancel.mockResolvedValueOnce(execution);
|
|
57
|
+
|
|
58
|
+
const { result } = renderHook(
|
|
59
|
+
() => useAgentExecutionActions("aex-001"),
|
|
60
|
+
{ wrapper: createWrapper(makeMockClient()) },
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(result.current.isSubmitting).toBe(false);
|
|
64
|
+
expect(result.current.error).toBeNull();
|
|
65
|
+
|
|
66
|
+
let returned: any;
|
|
67
|
+
await act(async () => {
|
|
68
|
+
returned = await result.current.cancel("No longer needed");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(returned).toBe(execution);
|
|
72
|
+
expect(result.current.isSubmitting).toBe(false);
|
|
73
|
+
expect(result.current.error).toBeNull();
|
|
74
|
+
expect(mockCancel).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(mockCancel.mock.calls[0][0]).toMatchObject({
|
|
76
|
+
id: "aex-001",
|
|
77
|
+
reason: "No longer needed",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("terminate returns updated execution on success", async () => {
|
|
82
|
+
const execution = makeExecution("aex-001", 6 /* TERMINATED */);
|
|
83
|
+
mockTerminate.mockResolvedValueOnce(execution);
|
|
84
|
+
|
|
85
|
+
const { result } = renderHook(
|
|
86
|
+
() => useAgentExecutionActions("aex-001"),
|
|
87
|
+
{ wrapper: createWrapper(makeMockClient()) },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
let returned: any;
|
|
91
|
+
await act(async () => {
|
|
92
|
+
returned = await result.current.terminate("Force stop");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(returned).toBe(execution);
|
|
96
|
+
expect(mockTerminate).toHaveBeenCalledTimes(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("sets error and returns null on failure", async () => {
|
|
100
|
+
mockCancel.mockRejectedValueOnce(new Error("Temporal not found"));
|
|
101
|
+
|
|
102
|
+
const { result } = renderHook(
|
|
103
|
+
() => useAgentExecutionActions("aex-001"),
|
|
104
|
+
{ wrapper: createWrapper(makeMockClient()) },
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
let returned: any;
|
|
108
|
+
await act(async () => {
|
|
109
|
+
returned = await result.current.cancel();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(returned).toBeNull();
|
|
113
|
+
expect(result.current.error).toBeInstanceOf(Error);
|
|
114
|
+
expect(result.current.error!.message).toBe("Temporal not found");
|
|
115
|
+
expect(result.current.isSubmitting).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("clearError resets error to null", async () => {
|
|
119
|
+
mockPause.mockRejectedValueOnce(new Error("oops"));
|
|
120
|
+
|
|
121
|
+
const { result } = renderHook(
|
|
122
|
+
() => useAgentExecutionActions("aex-001"),
|
|
123
|
+
{ wrapper: createWrapper(makeMockClient()) },
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
await act(async () => {
|
|
127
|
+
await result.current.pause();
|
|
128
|
+
});
|
|
129
|
+
expect(result.current.error).not.toBeNull();
|
|
130
|
+
|
|
131
|
+
act(() => result.current.clearError());
|
|
132
|
+
expect(result.current.error).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("error is cleared on next action attempt", async () => {
|
|
136
|
+
mockCancel
|
|
137
|
+
.mockRejectedValueOnce(new Error("first attempt failed"))
|
|
138
|
+
.mockResolvedValueOnce(makeExecution());
|
|
139
|
+
|
|
140
|
+
const { result } = renderHook(
|
|
141
|
+
() => useAgentExecutionActions("aex-001"),
|
|
142
|
+
{ wrapper: createWrapper(makeMockClient()) },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
await act(async () => {
|
|
146
|
+
await result.current.cancel();
|
|
147
|
+
});
|
|
148
|
+
expect(result.current.error!.message).toBe("first attempt failed");
|
|
149
|
+
|
|
150
|
+
await act(async () => {
|
|
151
|
+
await result.current.cancel();
|
|
152
|
+
});
|
|
153
|
+
expect(result.current.error).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("null executionId returns null without calling SDK", async () => {
|
|
157
|
+
const { result } = renderHook(
|
|
158
|
+
() => useAgentExecutionActions(null),
|
|
159
|
+
{ wrapper: createWrapper(makeMockClient()) },
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
let returned: any;
|
|
163
|
+
await act(async () => {
|
|
164
|
+
returned = await result.current.cancel();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(returned).toBeNull();
|
|
168
|
+
expect(mockCancel).not.toHaveBeenCalled();
|
|
169
|
+
expect(result.current.isSubmitting).toBe(false);
|
|
170
|
+
expect(result.current.error).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("resume sends only the id", async () => {
|
|
174
|
+
mockResume.mockResolvedValueOnce(makeExecution("aex-001", 2));
|
|
175
|
+
|
|
176
|
+
const { result } = renderHook(
|
|
177
|
+
() => useAgentExecutionActions("aex-001"),
|
|
178
|
+
{ wrapper: createWrapper(makeMockClient()) },
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
await act(async () => {
|
|
182
|
+
await result.current.resume();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(mockResume).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(mockResume.mock.calls[0][0]).toMatchObject({ id: "aex-001" });
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// onSuccess callback
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
describe("onSuccess callback", () => {
|
|
195
|
+
it("fires after a successful lifecycle action", async () => {
|
|
196
|
+
const execution = makeExecution("aex-001", 5);
|
|
197
|
+
mockCancel.mockResolvedValueOnce(execution);
|
|
198
|
+
const onSuccess = vi.fn();
|
|
199
|
+
|
|
200
|
+
const { result } = renderHook(
|
|
201
|
+
() => useAgentExecutionActions("aex-001", { onSuccess }),
|
|
202
|
+
{ wrapper: createWrapper(makeMockClient()) },
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
await act(async () => {
|
|
206
|
+
await result.current.cancel();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(onSuccess).toHaveBeenCalledTimes(1);
|
|
210
|
+
expect(onSuccess).toHaveBeenCalledWith(execution);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("does not fire on failure", async () => {
|
|
214
|
+
mockCancel.mockRejectedValueOnce(new Error("fail"));
|
|
215
|
+
const onSuccess = vi.fn();
|
|
216
|
+
|
|
217
|
+
const { result } = renderHook(
|
|
218
|
+
() => useAgentExecutionActions("aex-001", { onSuccess }),
|
|
219
|
+
{ wrapper: createWrapper(makeMockClient()) },
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
await act(async () => {
|
|
223
|
+
await result.current.cancel();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(onSuccess).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// stop() — progressive escalation cancel -> terminate
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
describe("stop escalation", () => {
|
|
235
|
+
it("first stop cancels, second stop terminates (same execution)", async () => {
|
|
236
|
+
mockCancel.mockResolvedValue(makeExecution("aex-001", 2));
|
|
237
|
+
mockTerminate.mockResolvedValue(makeExecution("aex-001", 6));
|
|
238
|
+
|
|
239
|
+
const { result } = renderHook(
|
|
240
|
+
() => useAgentExecutionActions("aex-001"),
|
|
241
|
+
{ wrapper: createWrapper(makeMockClient()) },
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
await act(async () => {
|
|
245
|
+
await result.current.stop("Stop from chat");
|
|
246
|
+
});
|
|
247
|
+
expect(mockCancel).toHaveBeenCalledTimes(1);
|
|
248
|
+
expect(mockTerminate).not.toHaveBeenCalled();
|
|
249
|
+
|
|
250
|
+
await act(async () => {
|
|
251
|
+
await result.current.stop("Stop from chat");
|
|
252
|
+
});
|
|
253
|
+
expect(mockCancel).toHaveBeenCalledTimes(1);
|
|
254
|
+
expect(mockTerminate).toHaveBeenCalledTimes(1);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("escalation resets when the execution id changes", async () => {
|
|
258
|
+
mockCancel.mockResolvedValue(makeExecution());
|
|
259
|
+
mockTerminate.mockResolvedValue(makeExecution());
|
|
260
|
+
|
|
261
|
+
const { result, rerender } = renderHook(
|
|
262
|
+
({ id }: { id: string | null }) => useAgentExecutionActions(id),
|
|
263
|
+
{
|
|
264
|
+
wrapper: createWrapper(makeMockClient()),
|
|
265
|
+
initialProps: { id: "aex-001" as string | null },
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await act(async () => {
|
|
270
|
+
await result.current.stop();
|
|
271
|
+
});
|
|
272
|
+
expect(mockCancel).toHaveBeenCalledTimes(1);
|
|
273
|
+
|
|
274
|
+
// A new in-flight execution — stop should cancel gracefully again,
|
|
275
|
+
// not inherit the prior execution's "escalate" state.
|
|
276
|
+
rerender({ id: "aex-002" });
|
|
277
|
+
await act(async () => {
|
|
278
|
+
await result.current.stop();
|
|
279
|
+
});
|
|
280
|
+
expect(mockCancel).toHaveBeenCalledTimes(2);
|
|
281
|
+
expect(mockTerminate).not.toHaveBeenCalled();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("stop is a no-op for a null execution id", async () => {
|
|
285
|
+
const { result } = renderHook(
|
|
286
|
+
() => useAgentExecutionActions(null),
|
|
287
|
+
{ wrapper: createWrapper(makeMockClient()) },
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
let returned: any;
|
|
291
|
+
await act(async () => {
|
|
292
|
+
returned = await result.current.stop();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(returned).toBeNull();
|
|
296
|
+
expect(mockCancel).not.toHaveBeenCalled();
|
|
297
|
+
expect(mockTerminate).not.toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
});
|
package/src/execution/index.ts
CHANGED
|
@@ -10,6 +10,12 @@ export { isTerminalPhase } from "./execution-phases";
|
|
|
10
10
|
export { useExecutionStream } from "./useExecutionStream";
|
|
11
11
|
export type { UseExecutionStreamReturn } from "./useExecutionStream";
|
|
12
12
|
|
|
13
|
+
export { useAgentExecutionActions } from "./useAgentExecutionActions";
|
|
14
|
+
export type {
|
|
15
|
+
UseAgentExecutionActionsOptions,
|
|
16
|
+
UseAgentExecutionActionsReturn,
|
|
17
|
+
} from "./useAgentExecutionActions";
|
|
18
|
+
|
|
13
19
|
export { UsageWidget, formatCost, formatTokenCount } from "./UsageWidget";
|
|
14
20
|
export type { UsageWidgetProps } from "./UsageWidget";
|
|
15
21
|
|