@stigmer/react 3.0.8-dev.20260613041848 → 3.0.8-dev.20260613071809
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 +3 -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 +2 -0
- package/composer/icons.d.ts.map +1 -1
- package/composer/icons.js +4 -0
- package/composer/icons.js.map +1 -1
- package/execution/ExecutionProgress.d.ts.map +1 -1
- package/execution/ExecutionProgress.js +5 -1
- package/execution/ExecutionProgress.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 +45 -3
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +66 -11
- 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/execution/useExecutionStream.d.ts +27 -0
- package/execution/useExecutionStream.d.ts.map +1 -1
- package/execution/useExecutionStream.js +48 -5
- package/execution/useExecutionStream.js.map +1 -1
- 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 +4 -1
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +5 -2
- package/internal/VirtualizedThread.js.map +1 -1
- package/internal/store/conversation-store.d.ts +22 -0
- package/internal/store/conversation-store.d.ts.map +1 -1
- package/internal/store/conversation-store.js +43 -2
- package/internal/store/conversation-store.js.map +1 -1
- package/internal/stream-controller.d.ts +46 -2
- package/internal/stream-controller.d.ts.map +1 -1
- package/internal/stream-controller.js +95 -4
- 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 +39 -2
- package/session/SessionViewer.js.map +1 -1
- package/session/useSessionConversation.d.ts +55 -3
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +95 -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/composer/ComposerToolbar.tsx +32 -9
- package/src/composer/SessionComposer.tsx +22 -1
- package/src/composer/__tests__/SessionComposer-stop.test.tsx +98 -0
- package/src/composer/icons.tsx +15 -0
- package/src/execution/ExecutionProgress.tsx +12 -0
- package/src/execution/MessageEntry.tsx +57 -2
- package/src/execution/MessageThread.tsx +203 -5
- package/src/execution/__tests__/MessageThread.test.tsx +130 -0
- package/src/execution/__tests__/useAgentExecutionActions.test.tsx +299 -0
- package/src/execution/__tests__/useExecutionStream.test.tsx +95 -0
- package/src/execution/index.ts +6 -0
- package/src/execution/useAgentExecutionActions.ts +205 -0
- package/src/execution/useExecutionStream.ts +80 -4
- package/src/index.ts +3 -0
- package/src/internal/VirtualizedThread.tsx +10 -1
- package/src/internal/__tests__/stream-controller.test.ts +165 -10
- package/src/internal/__tests__/useFetch.test.tsx +59 -0
- package/src/internal/store/__tests__/conversation-store.test.ts +61 -0
- package/src/internal/store/conversation-store.ts +46 -3
- package/src/internal/stream-controller.ts +123 -3
- package/src/internal/useFetch.ts +26 -0
- package/src/session/SessionViewer.tsx +87 -1
- package/src/session/__tests__/useSessionConversation.test.tsx +145 -0
- package/src/session/useSessionConversation.ts +163 -14
- package/src/session/useSessionExecutions.ts +23 -1
- package/styles.css +1 -1
|
@@ -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
|
+
});
|
|
@@ -521,3 +521,98 @@ describe("useExecutionStream — auto-reconnect", () => {
|
|
|
521
521
|
expect(subscribeFn).toHaveBeenCalledTimes(1);
|
|
522
522
|
});
|
|
523
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
|
+
});
|
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
|
|
|
@@ -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
|
+
}
|