@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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
|
+
import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
5
|
+
import { create } from "@bufbuild/protobuf";
|
|
6
|
+
import {
|
|
7
|
+
CancelAgentExecutionInputSchema,
|
|
8
|
+
TerminateAgentExecutionInputSchema,
|
|
9
|
+
PauseAgentExecutionInputSchema,
|
|
10
|
+
ResumeAgentExecutionInputSchema,
|
|
11
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/io_pb";
|
|
12
|
+
import { useStigmer } from "../hooks";
|
|
13
|
+
import { toError } from "../internal/toError";
|
|
14
|
+
|
|
15
|
+
/** Options for {@link useAgentExecutionActions}. */
|
|
16
|
+
export interface UseAgentExecutionActionsOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Called after any lifecycle action (cancel, terminate, pause, resume)
|
|
19
|
+
* succeeds. Receives the updated execution returned by the server.
|
|
20
|
+
* Useful for triggering a refetch so the UI reflects the new phase.
|
|
21
|
+
*/
|
|
22
|
+
readonly onSuccess?: (execution: AgentExecution) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Return value of {@link useAgentExecutionActions}. */
|
|
26
|
+
export interface UseAgentExecutionActionsReturn {
|
|
27
|
+
/** Cancel a running execution gracefully (PENDING or IN_PROGRESS only). */
|
|
28
|
+
readonly cancel: (reason?: string) => Promise<AgentExecution | null>;
|
|
29
|
+
/** Terminate a running execution immediately (PENDING or IN_PROGRESS only). */
|
|
30
|
+
readonly terminate: (reason?: string) => Promise<AgentExecution | null>;
|
|
31
|
+
/** Pause a running execution (PENDING or IN_PROGRESS only). */
|
|
32
|
+
readonly pause: (reason?: string) => Promise<AgentExecution | null>;
|
|
33
|
+
/** Resume a paused execution. */
|
|
34
|
+
readonly resume: () => Promise<AgentExecution | null>;
|
|
35
|
+
/**
|
|
36
|
+
* Stop a running execution with progressive escalation.
|
|
37
|
+
*
|
|
38
|
+
* The first call gracefully {@link cancel}s — the agent gets a chance to
|
|
39
|
+
* checkpoint and clean up. If the user presses Stop again because the run
|
|
40
|
+
* is still winding down, this escalates to a forceful {@link terminate}.
|
|
41
|
+
* The escalation state is keyed to the execution id, so a fresh execution
|
|
42
|
+
* always starts from a graceful cancel.
|
|
43
|
+
*
|
|
44
|
+
* @param reason - Optional audit message recorded with the cancel/terminate.
|
|
45
|
+
*/
|
|
46
|
+
readonly stop: (reason?: string) => Promise<AgentExecution | null>;
|
|
47
|
+
/** `true` while any action is in flight. */
|
|
48
|
+
readonly isSubmitting: boolean;
|
|
49
|
+
/** Error from the last failed action, or `null`. */
|
|
50
|
+
readonly error: Error | null;
|
|
51
|
+
/** Reset `error` to `null`. */
|
|
52
|
+
readonly clearError: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Behavior hook that encapsulates agent execution lifecycle actions.
|
|
57
|
+
*
|
|
58
|
+
* The agent-execution analog of {@link useWorkflowExecutionActions}: each
|
|
59
|
+
* action calls the corresponding RPC and returns the updated execution, or
|
|
60
|
+
* `null` on failure (with `error` populated).
|
|
61
|
+
*
|
|
62
|
+
* Pass `null` for `executionId` to disable all actions (they become no-ops
|
|
63
|
+
* that return `null`).
|
|
64
|
+
*
|
|
65
|
+
* Headless by design — embedders can wire a custom Stop/Cancel control
|
|
66
|
+
* directly to this hook. The session chat uses it via
|
|
67
|
+
* {@link useSessionConversation}'s `stop` / `isStoppable`.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```tsx
|
|
71
|
+
* const actions = useAgentExecutionActions(executionId, {
|
|
72
|
+
* onSuccess: () => refetch(),
|
|
73
|
+
* });
|
|
74
|
+
*
|
|
75
|
+
* // A single Stop button that escalates on repeat press.
|
|
76
|
+
* <button onClick={() => actions.stop()} disabled={actions.isSubmitting}>
|
|
77
|
+
* Stop
|
|
78
|
+
* </button>
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function useAgentExecutionActions(
|
|
82
|
+
executionId: string | null,
|
|
83
|
+
options?: UseAgentExecutionActionsOptions,
|
|
84
|
+
): UseAgentExecutionActionsReturn {
|
|
85
|
+
const stigmer = useStigmer();
|
|
86
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
87
|
+
const [error, setError] = useState<Error | null>(null);
|
|
88
|
+
|
|
89
|
+
const executionIdRef = useRef(executionId);
|
|
90
|
+
executionIdRef.current = executionId;
|
|
91
|
+
const stigmerRef = useRef(stigmer);
|
|
92
|
+
stigmerRef.current = stigmer;
|
|
93
|
+
const onSuccessRef = useRef(options?.onSuccess);
|
|
94
|
+
onSuccessRef.current = options?.onSuccess;
|
|
95
|
+
|
|
96
|
+
const clearError = useCallback(() => setError(null), []);
|
|
97
|
+
|
|
98
|
+
const wrap = useCallback(
|
|
99
|
+
async (
|
|
100
|
+
fn: () => Promise<AgentExecution>,
|
|
101
|
+
): Promise<AgentExecution | null> => {
|
|
102
|
+
if (!executionIdRef.current) return null;
|
|
103
|
+
setIsSubmitting(true);
|
|
104
|
+
setError(null);
|
|
105
|
+
try {
|
|
106
|
+
const result = await fn();
|
|
107
|
+
onSuccessRef.current?.(result);
|
|
108
|
+
return result;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
setError(toError(err));
|
|
111
|
+
return null;
|
|
112
|
+
} finally {
|
|
113
|
+
setIsSubmitting(false);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
[],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const cancel = useCallback(
|
|
120
|
+
(reason?: string) =>
|
|
121
|
+
wrap(() =>
|
|
122
|
+
stigmerRef.current.agentExecution.cancel(
|
|
123
|
+
create(CancelAgentExecutionInputSchema, {
|
|
124
|
+
id: executionIdRef.current!,
|
|
125
|
+
reason: reason ?? "",
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
[wrap],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const terminate = useCallback(
|
|
133
|
+
(reason?: string) =>
|
|
134
|
+
wrap(() =>
|
|
135
|
+
stigmerRef.current.agentExecution.terminate(
|
|
136
|
+
create(TerminateAgentExecutionInputSchema, {
|
|
137
|
+
id: executionIdRef.current!,
|
|
138
|
+
reason: reason ?? "",
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
[wrap],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const pause = useCallback(
|
|
146
|
+
(reason?: string) =>
|
|
147
|
+
wrap(() =>
|
|
148
|
+
stigmerRef.current.agentExecution.pause(
|
|
149
|
+
create(PauseAgentExecutionInputSchema, {
|
|
150
|
+
id: executionIdRef.current!,
|
|
151
|
+
reason: reason ?? "",
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
[wrap],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const resume = useCallback(
|
|
159
|
+
() =>
|
|
160
|
+
wrap(() =>
|
|
161
|
+
stigmerRef.current.agentExecution.resume(
|
|
162
|
+
create(ResumeAgentExecutionInputSchema, {
|
|
163
|
+
id: executionIdRef.current!,
|
|
164
|
+
}),
|
|
165
|
+
),
|
|
166
|
+
),
|
|
167
|
+
[wrap],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Escalation state: remembers whether a graceful cancel has already been
|
|
171
|
+
// issued for the current execution id. Keyed by id so a new execution
|
|
172
|
+
// always begins with cancel rather than inheriting a stale "escalate" flag.
|
|
173
|
+
const stopStateRef = useRef<{ id: string; cancelIssued: boolean } | null>(
|
|
174
|
+
null,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const stop = useCallback(
|
|
178
|
+
(reason?: string): Promise<AgentExecution | null> => {
|
|
179
|
+
const id = executionIdRef.current;
|
|
180
|
+
if (!id) return Promise.resolve(null);
|
|
181
|
+
|
|
182
|
+
if (stopStateRef.current?.id !== id) {
|
|
183
|
+
stopStateRef.current = { id, cancelIssued: false };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!stopStateRef.current.cancelIssued) {
|
|
187
|
+
stopStateRef.current.cancelIssued = true;
|
|
188
|
+
return cancel(reason);
|
|
189
|
+
}
|
|
190
|
+
return terminate(reason);
|
|
191
|
+
},
|
|
192
|
+
[cancel, terminate],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
cancel,
|
|
197
|
+
terminate,
|
|
198
|
+
pause,
|
|
199
|
+
resume,
|
|
200
|
+
stop,
|
|
201
|
+
isSubmitting,
|
|
202
|
+
error,
|
|
203
|
+
clearError,
|
|
204
|
+
};
|
|
205
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -180,6 +180,7 @@ export {
|
|
|
180
180
|
isTerminalPhase,
|
|
181
181
|
useCreateAgentExecution,
|
|
182
182
|
useExecutionStream,
|
|
183
|
+
useAgentExecutionActions,
|
|
183
184
|
useSubmitApproval,
|
|
184
185
|
ExecutionPhaseBadge,
|
|
185
186
|
InteractionModeBadge,
|
|
@@ -251,6 +252,8 @@ export type {
|
|
|
251
252
|
CreateAgentExecutionResult,
|
|
252
253
|
UseCreateAgentExecutionReturn,
|
|
253
254
|
UseExecutionStreamReturn,
|
|
255
|
+
UseAgentExecutionActionsOptions,
|
|
256
|
+
UseAgentExecutionActionsReturn,
|
|
254
257
|
UseSubmitApprovalReturn,
|
|
255
258
|
ExecutionPhaseBadgeProps,
|
|
256
259
|
InteractionModeBadgeProps,
|
|
@@ -43,6 +43,7 @@ export interface VirtualizedThreadProps {
|
|
|
43
43
|
readonly centerContent?: boolean;
|
|
44
44
|
readonly onRetrySend?: () => void;
|
|
45
45
|
readonly onRetryExecution?: (message: string) => void;
|
|
46
|
+
readonly onEditMessage?: (text: string) => void;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
// ---------------------------------------------------------------------------
|
|
@@ -105,6 +106,7 @@ export function VirtualizedThread({
|
|
|
105
106
|
centerContent,
|
|
106
107
|
onRetrySend,
|
|
107
108
|
onRetryExecution,
|
|
109
|
+
onEditMessage,
|
|
108
110
|
}: VirtualizedThreadProps) {
|
|
109
111
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
|
110
112
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -138,8 +140,9 @@ export function VirtualizedThread({
|
|
|
138
140
|
planActionsDisabled,
|
|
139
141
|
onRetrySend,
|
|
140
142
|
onRetryExecution,
|
|
143
|
+
onEditMessage,
|
|
141
144
|
}),
|
|
142
|
-
[formatToolCallSummary, onApprovalSubmit, submittingApprovalIds, onBuildFromPlan, org, planActionsDisabled, onRetrySend, onRetryExecution],
|
|
145
|
+
[formatToolCallSummary, onApprovalSubmit, submittingApprovalIds, onBuildFromPlan, org, planActionsDisabled, onRetrySend, onRetryExecution, onEditMessage],
|
|
143
146
|
);
|
|
144
147
|
|
|
145
148
|
return (
|
|
@@ -288,7 +288,8 @@ function ConversationColumn({
|
|
|
288
288
|
isEndUser,
|
|
289
289
|
}: ConversationColumnProps) {
|
|
290
290
|
const { conv } = flow;
|
|
291
|
-
const sendError =
|
|
291
|
+
const sendError =
|
|
292
|
+
flow.submitError ?? conv.sendError ?? conv.approvalError ?? conv.stopError;
|
|
292
293
|
|
|
293
294
|
// Retry a terminal-failed execution by resending its originating message
|
|
294
295
|
// through the full submit pipeline (agent override, runtime-env, workspace).
|
|
@@ -299,6 +300,26 @@ function ConversationColumn({
|
|
|
299
300
|
[flow.handleSubmit],
|
|
300
301
|
);
|
|
301
302
|
|
|
303
|
+
// Stop the in-flight turn (graceful cancel, escalating to terminate on a
|
|
304
|
+
// repeat press). Only wired while the active execution is stoppable.
|
|
305
|
+
const handleStop = useCallback(() => {
|
|
306
|
+
void conv.stop();
|
|
307
|
+
}, [conv.stop]);
|
|
308
|
+
|
|
309
|
+
// Edit-and-resubmit: stop the in-flight turn and pre-fill the composer with
|
|
310
|
+
// the original text. The append-only execution log can't be rewritten, so
|
|
311
|
+
// the user reviews the prefilled message and resubmits it as a NEW execution
|
|
312
|
+
// through the normal Send pipeline. The cancelled turn stays in history with
|
|
313
|
+
// its phase badge — an honest record rather than a silent edit.
|
|
314
|
+
const handleEditMessage = useCallback(
|
|
315
|
+
(text: string) => {
|
|
316
|
+
void conv.stop();
|
|
317
|
+
composerRef.current?.setMessage(text);
|
|
318
|
+
composerRef.current?.focus();
|
|
319
|
+
},
|
|
320
|
+
[conv.stop, composerRef],
|
|
321
|
+
);
|
|
322
|
+
|
|
302
323
|
return (
|
|
303
324
|
<div className="flex h-full min-w-0 flex-col">
|
|
304
325
|
<MessageThread
|
|
@@ -310,6 +331,7 @@ function ConversationColumn({
|
|
|
310
331
|
onRetryExecution={onRetryExecution}
|
|
311
332
|
onApprovalSubmit={flow.submitApproval}
|
|
312
333
|
submittingApprovalIds={conv.submittingApprovalIds}
|
|
334
|
+
onEditMessage={conv.isStoppable ? handleEditMessage : undefined}
|
|
313
335
|
workspaceEntries={conv.workspaceEntries}
|
|
314
336
|
sandboxWorkspaceRoot={flow.sandboxWorkspaceRoot}
|
|
315
337
|
onBuildFromPlan={onBuildFromPlan}
|
|
@@ -339,6 +361,8 @@ function ConversationColumn({
|
|
|
339
361
|
onSubmit={flow.handleSubmit}
|
|
340
362
|
isSubmitting={conv.isSending}
|
|
341
363
|
disabled={!conv.canSendFollowUp}
|
|
364
|
+
onStop={conv.isStoppable ? handleStop : undefined}
|
|
365
|
+
isStopping={conv.isStopping}
|
|
342
366
|
org={org}
|
|
343
367
|
harness={flow.harness}
|
|
344
368
|
defaultModelId={modelId}
|
|
@@ -81,6 +81,8 @@ interface MockMethods {
|
|
|
81
81
|
executionCreate: ReturnType<typeof vi.fn>;
|
|
82
82
|
subscribe: ReturnType<typeof vi.fn>;
|
|
83
83
|
submitApproval: ReturnType<typeof vi.fn>;
|
|
84
|
+
cancel?: ReturnType<typeof vi.fn>;
|
|
85
|
+
terminate?: ReturnType<typeof vi.fn>;
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
function createMockStigmer(methods: MockMethods): Stigmer {
|
|
@@ -93,6 +95,8 @@ function createMockStigmer(methods: MockMethods): Stigmer {
|
|
|
93
95
|
create: methods.executionCreate,
|
|
94
96
|
subscribe: methods.subscribe,
|
|
95
97
|
submitApproval: methods.submitApproval,
|
|
98
|
+
cancel: methods.cancel,
|
|
99
|
+
terminate: methods.terminate,
|
|
96
100
|
},
|
|
97
101
|
} as unknown as Stigmer;
|
|
98
102
|
}
|
|
@@ -118,6 +122,8 @@ describe("useSessionConversation", () => {
|
|
|
118
122
|
executionCreate: vi.fn(),
|
|
119
123
|
subscribe: vi.fn().mockReturnValue(stream.generator),
|
|
120
124
|
submitApproval: vi.fn().mockResolvedValue({}),
|
|
125
|
+
cancel: vi.fn().mockResolvedValue(makeExecution("e1", ExecutionPhase.EXECUTION_CANCELLED)),
|
|
126
|
+
terminate: vi.fn().mockResolvedValue(makeExecution("e1", ExecutionPhase.EXECUTION_TERMINATED)),
|
|
121
127
|
};
|
|
122
128
|
mockStigmer = createMockStigmer(methods);
|
|
123
129
|
});
|
|
@@ -411,6 +417,92 @@ describe("useSessionConversation", () => {
|
|
|
411
417
|
}),
|
|
412
418
|
);
|
|
413
419
|
});
|
|
420
|
+
|
|
421
|
+
it("isStoppable is false when no execution is active", async () => {
|
|
422
|
+
const completed = makeExecution("e1", ExecutionPhase.EXECUTION_COMPLETED);
|
|
423
|
+
methods.listBySession.mockResolvedValue({ entries: [completed] });
|
|
424
|
+
|
|
425
|
+
const { result } = renderHook(
|
|
426
|
+
() => useSessionConversation("session-1", "org"),
|
|
427
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
431
|
+
expect(result.current.isStoppable).toBe(false);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("isStoppable becomes true once the active execution streams IN_PROGRESS", async () => {
|
|
435
|
+
const active = makeExecution("e1", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
436
|
+
methods.listBySession.mockResolvedValue({ entries: [active] });
|
|
437
|
+
|
|
438
|
+
const execStream = createControllableStream<AgentExecution>();
|
|
439
|
+
methods.subscribe.mockReturnValue(execStream.generator);
|
|
440
|
+
|
|
441
|
+
const { result } = renderHook(
|
|
442
|
+
() => useSessionConversation("session-1", "org"),
|
|
443
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
447
|
+
|
|
448
|
+
act(() => {
|
|
449
|
+
execStream.push(makeExecution("e1", ExecutionPhase.EXECUTION_IN_PROGRESS));
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
await waitFor(() => expect(result.current.isStoppable).toBe(true));
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("stop() cancels the active execution, then escalates to terminate on repeat", async () => {
|
|
456
|
+
const active = makeExecution("e1", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
457
|
+
methods.listBySession.mockResolvedValue({ entries: [active] });
|
|
458
|
+
|
|
459
|
+
const execStream = createControllableStream<AgentExecution>();
|
|
460
|
+
methods.subscribe.mockReturnValue(execStream.generator);
|
|
461
|
+
|
|
462
|
+
const { result } = renderHook(
|
|
463
|
+
() => useSessionConversation("session-1", "org"),
|
|
464
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
468
|
+
|
|
469
|
+
act(() => {
|
|
470
|
+
execStream.push(makeExecution("e1", ExecutionPhase.EXECUTION_IN_PROGRESS));
|
|
471
|
+
});
|
|
472
|
+
await waitFor(() => expect(result.current.isStoppable).toBe(true));
|
|
473
|
+
|
|
474
|
+
await act(async () => {
|
|
475
|
+
await result.current.stop("Stop from chat");
|
|
476
|
+
});
|
|
477
|
+
expect(methods.cancel).toHaveBeenCalledTimes(1);
|
|
478
|
+
expect(methods.cancel!.mock.calls[0][0]).toMatchObject({ id: "e1" });
|
|
479
|
+
expect(methods.terminate).not.toHaveBeenCalled();
|
|
480
|
+
|
|
481
|
+
await act(async () => {
|
|
482
|
+
await result.current.stop("Stop from chat");
|
|
483
|
+
});
|
|
484
|
+
expect(methods.cancel).toHaveBeenCalledTimes(1);
|
|
485
|
+
expect(methods.terminate).toHaveBeenCalledTimes(1);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("stop() is a no-op when nothing is stoppable", async () => {
|
|
489
|
+
const completed = makeExecution("e1", ExecutionPhase.EXECUTION_COMPLETED);
|
|
490
|
+
methods.listBySession.mockResolvedValue({ entries: [completed] });
|
|
491
|
+
|
|
492
|
+
const { result } = renderHook(
|
|
493
|
+
() => useSessionConversation("session-1", "org"),
|
|
494
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
498
|
+
|
|
499
|
+
await act(async () => {
|
|
500
|
+
await result.current.stop();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
expect(methods.cancel).not.toHaveBeenCalled();
|
|
504
|
+
expect(methods.terminate).not.toHaveBeenCalled();
|
|
505
|
+
});
|
|
414
506
|
});
|
|
415
507
|
|
|
416
508
|
describe("useSessionConversation — local runner worker lifecycle", () => {
|
|
@@ -21,6 +21,7 @@ import { toError } from "../internal/toError";
|
|
|
21
21
|
import { useConversationStoreRef } from "../internal/store";
|
|
22
22
|
import { useCreateAgentExecution } from "../execution/useCreateAgentExecution";
|
|
23
23
|
import { useExecutionStream } from "../execution/useExecutionStream";
|
|
24
|
+
import { useAgentExecutionActions } from "../execution/useAgentExecutionActions";
|
|
24
25
|
import { useSubmitApproval } from "../execution/useSubmitApproval";
|
|
25
26
|
import { useSession } from "./useSession";
|
|
26
27
|
import { useSessionExecutions } from "./useSessionExecutions";
|
|
@@ -200,6 +201,28 @@ export interface UseSessionConversationReturn {
|
|
|
200
201
|
/** Reset `approvalError` to `null`. */
|
|
201
202
|
readonly clearApprovalError: () => void;
|
|
202
203
|
|
|
204
|
+
/**
|
|
205
|
+
* `true` when the active execution can be stopped — i.e. it is in a phase
|
|
206
|
+
* the backend accepts a cancel/terminate from (`PENDING` or `IN_PROGRESS`).
|
|
207
|
+
*
|
|
208
|
+
* Distinct from "is something active": an execution paused at an approval
|
|
209
|
+
* gate (`WAITING_FOR_APPROVAL`) is active but **not** stoppable — the
|
|
210
|
+
* approval card (approve / skip / reject) is its control surface. Drive the
|
|
211
|
+
* composer's Stop affordance off this so it only appears when {@link stop}
|
|
212
|
+
* will actually succeed.
|
|
213
|
+
*/
|
|
214
|
+
readonly isStoppable: boolean;
|
|
215
|
+
/**
|
|
216
|
+
* Stop the active execution, with progressive escalation: the first call
|
|
217
|
+
* gracefully cancels; a repeat call (because the run is still winding down)
|
|
218
|
+
* forcefully terminates. No-op when nothing is {@link isStoppable}.
|
|
219
|
+
*/
|
|
220
|
+
readonly stop: (reason?: string) => Promise<void>;
|
|
221
|
+
/** `true` while a stop (cancel/terminate) request is in flight. */
|
|
222
|
+
readonly isStopping: boolean;
|
|
223
|
+
/** Error from the last stop attempt, or `null` when healthy. */
|
|
224
|
+
readonly stopError: Error | null;
|
|
225
|
+
|
|
203
226
|
/** `true` while the session or execution list is loading. */
|
|
204
227
|
readonly isLoading: boolean;
|
|
205
228
|
/** Error from session or execution list loading, or `null` when healthy. */
|
|
@@ -431,6 +454,28 @@ export function useSessionConversation(
|
|
|
431
454
|
return stream.phase;
|
|
432
455
|
}, [activeExecutionId, stream.phase]);
|
|
433
456
|
|
|
457
|
+
// Stop is only valid in phases the backend cancels/terminates from
|
|
458
|
+
// (PENDING / IN_PROGRESS). Other non-terminal phases (e.g.
|
|
459
|
+
// WAITING_FOR_APPROVAL) are handled by their own control surface.
|
|
460
|
+
const isStoppable =
|
|
461
|
+
activePhase === ExecutionPhase.EXECUTION_PENDING ||
|
|
462
|
+
activePhase === ExecutionPhase.EXECUTION_IN_PROGRESS;
|
|
463
|
+
|
|
464
|
+
const stopActions = useAgentExecutionActions(activeExecutionId, {
|
|
465
|
+
// The cancel/terminate also broadcasts the new phase over the stream, but
|
|
466
|
+
// refetch is the belt-and-suspenders that clears the active id even if the
|
|
467
|
+
// stream has already ended — mirrors the workflow viewer's onSuccess.
|
|
468
|
+
onSuccess: refetch,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const stop = useCallback(
|
|
472
|
+
async (reason?: string): Promise<void> => {
|
|
473
|
+
if (!isStoppable) return;
|
|
474
|
+
await stopActions.stop(reason);
|
|
475
|
+
},
|
|
476
|
+
[isStoppable, stopActions.stop],
|
|
477
|
+
);
|
|
478
|
+
|
|
434
479
|
const canSendFollowUp = !isCreating && activeExecutionId === null;
|
|
435
480
|
|
|
436
481
|
const workspaceEntries = useMemo<readonly ProtoWorkspaceEntry[]>(
|
|
@@ -563,6 +608,11 @@ export function useSessionConversation(
|
|
|
563
608
|
approvalError,
|
|
564
609
|
clearApprovalError,
|
|
565
610
|
|
|
611
|
+
isStoppable,
|
|
612
|
+
stop,
|
|
613
|
+
isStopping: stopActions.isSubmitting,
|
|
614
|
+
stopError: stopActions.error,
|
|
615
|
+
|
|
566
616
|
isLoading,
|
|
567
617
|
loadError,
|
|
568
618
|
|