@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.
Files changed (59) hide show
  1. package/composer/ComposerToolbar.d.ts +9 -1
  2. package/composer/ComposerToolbar.d.ts.map +1 -1
  3. package/composer/ComposerToolbar.js +7 -3
  4. package/composer/ComposerToolbar.js.map +1 -1
  5. package/composer/SessionComposer.d.ts +12 -0
  6. package/composer/SessionComposer.d.ts.map +1 -1
  7. package/composer/SessionComposer.js +6 -3
  8. package/composer/SessionComposer.js.map +1 -1
  9. package/composer/icons.d.ts +7 -0
  10. package/composer/icons.d.ts.map +1 -1
  11. package/composer/icons.js +9 -0
  12. package/composer/icons.js.map +1 -1
  13. package/execution/MessageEntry.d.ts +7 -0
  14. package/execution/MessageEntry.d.ts.map +1 -1
  15. package/execution/MessageEntry.js +7 -4
  16. package/execution/MessageEntry.js.map +1 -1
  17. package/execution/MessageThread.d.ts +16 -3
  18. package/execution/MessageThread.d.ts.map +1 -1
  19. package/execution/MessageThread.js +15 -9
  20. package/execution/MessageThread.js.map +1 -1
  21. package/execution/index.d.ts +2 -0
  22. package/execution/index.d.ts.map +1 -1
  23. package/execution/index.js +1 -0
  24. package/execution/index.js.map +1 -1
  25. package/execution/useAgentExecutionActions.d.ts +67 -0
  26. package/execution/useAgentExecutionActions.d.ts.map +1 -0
  27. package/execution/useAgentExecutionActions.js +105 -0
  28. package/execution/useAgentExecutionActions.js.map +1 -0
  29. package/index.d.ts +2 -2
  30. package/index.d.ts.map +1 -1
  31. package/index.js +1 -1
  32. package/index.js.map +1 -1
  33. package/internal/VirtualizedThread.d.ts +2 -1
  34. package/internal/VirtualizedThread.d.ts.map +1 -1
  35. package/internal/VirtualizedThread.js +3 -2
  36. package/internal/VirtualizedThread.js.map +1 -1
  37. package/package.json +4 -4
  38. package/session/SessionViewer.js +17 -2
  39. package/session/SessionViewer.js.map +1 -1
  40. package/session/useSessionConversation.d.ts +21 -0
  41. package/session/useSessionConversation.d.ts.map +1 -1
  42. package/session/useSessionConversation.js +22 -0
  43. package/session/useSessionConversation.js.map +1 -1
  44. package/src/composer/ComposerToolbar.tsx +35 -9
  45. package/src/composer/SessionComposer.tsx +22 -1
  46. package/src/composer/__tests__/SessionComposer-stop.test.tsx +98 -0
  47. package/src/composer/icons.tsx +20 -0
  48. package/src/execution/MessageEntry.tsx +57 -2
  49. package/src/execution/MessageThread.tsx +32 -3
  50. package/src/execution/__tests__/MessageThread.test.tsx +66 -0
  51. package/src/execution/__tests__/useAgentExecutionActions.test.tsx +299 -0
  52. package/src/execution/index.ts +6 -0
  53. package/src/execution/useAgentExecutionActions.ts +205 -0
  54. package/src/index.ts +3 -0
  55. package/src/internal/VirtualizedThread.tsx +4 -1
  56. package/src/session/SessionViewer.tsx +25 -1
  57. package/src/session/__tests__/useSessionConversation.test.tsx +92 -0
  58. package/src/session/useSessionConversation.ts +50 -0
  59. 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 <HumanMessage content={message.content} className={className} />;
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("ms-[20%] rounded-lg bg-muted-subtle px-4 py-3", className)}
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
+ });
@@ -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