@lobehub/lobehub 2.0.0-next.84 → 2.0.0-next.86
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/CHANGELOG.md +50 -0
- package/apps/desktop/src/main/modules/networkProxy/dispatcher.ts +16 -16
- package/apps/desktop/src/main/modules/networkProxy/tester.ts +11 -11
- package/apps/desktop/src/main/modules/networkProxy/urlBuilder.ts +3 -3
- package/apps/desktop/src/main/modules/networkProxy/validator.ts +10 -10
- package/changelog/v1.json +18 -0
- package/package.json +1 -1
- package/packages/agent-runtime/src/core/runtime.ts +36 -1
- package/packages/agent-runtime/src/types/event.ts +1 -0
- package/packages/agent-runtime/src/types/generalAgent.ts +16 -0
- package/packages/agent-runtime/src/types/instruction.ts +30 -0
- package/packages/agent-runtime/src/types/runtime.ts +7 -0
- package/packages/types/src/message/common/metadata.ts +3 -0
- package/packages/types/src/message/common/tools.ts +2 -2
- package/packages/types/src/tool/search/index.ts +8 -2
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/index.tsx +2 -2
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/useSend.ts +7 -2
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/useSend.ts +15 -14
- package/src/app/[variants]/(main)/chat/session/features/SessionListContent/List/Item/index.tsx +2 -2
- package/src/app/[variants]/(main)/discover/(list)/features/Pagination.tsx +1 -1
- package/src/features/ChatInput/ActionBar/STT/browser.tsx +2 -2
- package/src/features/ChatInput/ActionBar/STT/openai.tsx +2 -2
- package/src/features/Conversation/Messages/Group/Tool/Inspector/index.tsx +1 -1
- package/src/features/Conversation/Messages/User/index.tsx +3 -3
- package/src/features/Conversation/Messages/index.tsx +3 -3
- package/src/features/Conversation/components/AutoScroll.tsx +2 -2
- package/src/services/search.ts +2 -2
- package/src/store/chat/agents/GeneralChatAgent.ts +98 -0
- package/src/store/chat/agents/__tests__/GeneralChatAgent.test.ts +366 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/call-llm.test.ts +1217 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/call-tool.test.ts +1976 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/finish.test.ts +453 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/index.ts +4 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockInstructions.ts +126 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockMessages.ts +94 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockOperations.ts +96 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockStore.ts +138 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/assertions.ts +185 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/index.ts +3 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/operationTestUtils.ts +94 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/testExecutor.ts +139 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/request-human-approve.test.ts +545 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/resolve-aborted-tools.test.ts +686 -0
- package/src/store/chat/agents/createAgentExecutors.ts +313 -80
- package/src/store/chat/selectors.ts +1 -0
- package/src/store/chat/slices/aiChat/__tests__/ai-chat.integration.test.ts +667 -0
- package/src/store/chat/slices/aiChat/actions/__tests__/cancel-functionality.test.ts +137 -27
- package/src/store/chat/slices/aiChat/actions/__tests__/conversationControl.test.ts +163 -125
- package/src/store/chat/slices/aiChat/actions/__tests__/conversationLifecycle.test.ts +12 -2
- package/src/store/chat/slices/aiChat/actions/__tests__/fixtures.ts +0 -2
- package/src/store/chat/slices/aiChat/actions/__tests__/helpers.ts +0 -2
- package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +286 -19
- package/src/store/chat/slices/aiChat/actions/__tests__/streamingStates.test.ts +0 -112
- package/src/store/chat/slices/aiChat/actions/conversationControl.ts +42 -99
- package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +90 -57
- package/src/store/chat/slices/aiChat/actions/generateAIGroupChat.ts +5 -25
- package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +220 -98
- package/src/store/chat/slices/aiChat/actions/streamingStates.ts +0 -34
- package/src/store/chat/slices/aiChat/initialState.ts +0 -28
- package/src/store/chat/slices/aiChat/selectors.test.ts +280 -0
- package/src/store/chat/slices/aiChat/selectors.ts +31 -7
- package/src/store/chat/slices/builtinTool/actions/__tests__/localSystem.test.ts +21 -30
- package/src/store/chat/slices/builtinTool/actions/__tests__/search.test.ts +29 -49
- package/src/store/chat/slices/builtinTool/actions/interpreter.ts +83 -48
- package/src/store/chat/slices/builtinTool/actions/localSystem.ts +78 -28
- package/src/store/chat/slices/builtinTool/actions/search.ts +146 -59
- package/src/store/chat/slices/builtinTool/selectors.test.ts +258 -0
- package/src/store/chat/slices/builtinTool/selectors.ts +25 -4
- package/src/store/chat/slices/message/action.test.ts +134 -16
- package/src/store/chat/slices/message/actions/internals.ts +33 -7
- package/src/store/chat/slices/message/actions/optimisticUpdate.ts +85 -52
- package/src/store/chat/slices/message/initialState.ts +0 -10
- package/src/store/chat/slices/message/selectors/messageState.ts +34 -12
- package/src/store/chat/slices/operation/__tests__/actions.test.ts +712 -16
- package/src/store/chat/slices/operation/__tests__/integration.test.ts +342 -0
- package/src/store/chat/slices/operation/__tests__/selectors.test.ts +257 -17
- package/src/store/chat/slices/operation/actions.ts +218 -11
- package/src/store/chat/slices/operation/selectors.ts +135 -6
- package/src/store/chat/slices/operation/types.ts +29 -3
- package/src/store/chat/slices/plugin/action.test.ts +30 -322
- package/src/store/chat/slices/plugin/actions/internals.ts +0 -14
- package/src/store/chat/slices/plugin/actions/optimisticUpdate.ts +21 -19
- package/src/store/chat/slices/plugin/actions/pluginTypes.ts +45 -27
- package/src/store/chat/slices/plugin/actions/publicApi.ts +3 -4
- package/src/store/chat/slices/plugin/actions/workflow.ts +0 -55
- package/src/store/chat/slices/thread/selectors/index.ts +4 -2
- package/src/store/chat/slices/translate/action.ts +54 -41
- package/src/tools/web-browsing/ExecutionRuntime/index.ts +5 -2
- package/src/tools/web-browsing/Portal/Search/Footer.tsx +11 -9
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { produce } from 'immer';
|
|
2
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
4
|
|
|
4
5
|
import { useChatStore } from '@/store/chat/store';
|
|
@@ -21,7 +22,7 @@ describe('Operation Actions', () => {
|
|
|
21
22
|
|
|
22
23
|
act(() => {
|
|
23
24
|
const res = result.current.startOperation({
|
|
24
|
-
type: '
|
|
25
|
+
type: 'execAgentRuntime',
|
|
25
26
|
context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
|
|
26
27
|
label: 'Generating...',
|
|
27
28
|
});
|
|
@@ -32,7 +33,7 @@ describe('Operation Actions', () => {
|
|
|
32
33
|
const operation = result.current.operations[operationId!];
|
|
33
34
|
|
|
34
35
|
expect(operation).toBeDefined();
|
|
35
|
-
expect(operation.type).toBe('
|
|
36
|
+
expect(operation.type).toBe('execAgentRuntime');
|
|
36
37
|
expect(operation.status).toBe('running');
|
|
37
38
|
expect(operation.context.sessionId).toBe('session1');
|
|
38
39
|
expect(operation.context.topicId).toBe('topic1');
|
|
@@ -57,7 +58,7 @@ describe('Operation Actions', () => {
|
|
|
57
58
|
|
|
58
59
|
// Create child operation (inherits context)
|
|
59
60
|
const child = result.current.startOperation({
|
|
60
|
-
type: '
|
|
61
|
+
type: 'execAgentRuntime',
|
|
61
62
|
context: { messageId: 'msg1' }, // Only override messageId
|
|
62
63
|
parentOperationId: parentOpId,
|
|
63
64
|
});
|
|
@@ -88,7 +89,7 @@ describe('Operation Actions', () => {
|
|
|
88
89
|
|
|
89
90
|
// Create child operation without context (undefined)
|
|
90
91
|
const child = result.current.startOperation({
|
|
91
|
-
type: '
|
|
92
|
+
type: 'execAgentRuntime',
|
|
92
93
|
parentOperationId: parentOpId,
|
|
93
94
|
});
|
|
94
95
|
childOpId = child.operationId;
|
|
@@ -110,13 +111,13 @@ describe('Operation Actions', () => {
|
|
|
110
111
|
|
|
111
112
|
act(() => {
|
|
112
113
|
operationId = result.current.startOperation({
|
|
113
|
-
type: '
|
|
114
|
+
type: 'execAgentRuntime',
|
|
114
115
|
context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
|
|
115
116
|
}).operationId;
|
|
116
117
|
});
|
|
117
118
|
|
|
118
119
|
// Check type index
|
|
119
|
-
expect(result.current.operationsByType.
|
|
120
|
+
expect(result.current.operationsByType.execAgentRuntime).toContain(operationId!);
|
|
120
121
|
|
|
121
122
|
// Check message index
|
|
122
123
|
expect(result.current.operationsByMessage.msg1).toContain(operationId!);
|
|
@@ -135,7 +136,7 @@ describe('Operation Actions', () => {
|
|
|
135
136
|
|
|
136
137
|
act(() => {
|
|
137
138
|
operationId = result.current.startOperation({
|
|
138
|
-
type: '
|
|
139
|
+
type: 'execAgentRuntime',
|
|
139
140
|
context: { sessionId: 'session1' },
|
|
140
141
|
}).operationId;
|
|
141
142
|
});
|
|
@@ -164,7 +165,7 @@ describe('Operation Actions', () => {
|
|
|
164
165
|
|
|
165
166
|
act(() => {
|
|
166
167
|
const res = result.current.startOperation({
|
|
167
|
-
type: '
|
|
168
|
+
type: 'execAgentRuntime',
|
|
168
169
|
context: { sessionId: 'session1' },
|
|
169
170
|
});
|
|
170
171
|
operationId = res.operationId;
|
|
@@ -191,7 +192,7 @@ describe('Operation Actions', () => {
|
|
|
191
192
|
|
|
192
193
|
act(() => {
|
|
193
194
|
parentOpId = result.current.startOperation({
|
|
194
|
-
type: '
|
|
195
|
+
type: 'execAgentRuntime',
|
|
195
196
|
context: { sessionId: 'session1' },
|
|
196
197
|
}).operationId;
|
|
197
198
|
|
|
@@ -214,6 +215,119 @@ describe('Operation Actions', () => {
|
|
|
214
215
|
expect(result.current.operations[child1OpId!].status).toBe('cancelled');
|
|
215
216
|
expect(result.current.operations[child2OpId!].status).toBe('cancelled');
|
|
216
217
|
});
|
|
218
|
+
|
|
219
|
+
it('should not cancel already completed child operations', () => {
|
|
220
|
+
const { result } = renderHook(() => useChatStore());
|
|
221
|
+
|
|
222
|
+
let parentOpId: string;
|
|
223
|
+
let completedChildOpId: string;
|
|
224
|
+
let runningChildOpId: string;
|
|
225
|
+
|
|
226
|
+
act(() => {
|
|
227
|
+
parentOpId = result.current.startOperation({
|
|
228
|
+
type: 'execAgentRuntime',
|
|
229
|
+
context: { sessionId: 'session1' },
|
|
230
|
+
}).operationId;
|
|
231
|
+
|
|
232
|
+
completedChildOpId = result.current.startOperation({
|
|
233
|
+
type: 'toolCalling',
|
|
234
|
+
parentOperationId: parentOpId,
|
|
235
|
+
}).operationId;
|
|
236
|
+
|
|
237
|
+
runningChildOpId = result.current.startOperation({
|
|
238
|
+
type: 'reasoning',
|
|
239
|
+
parentOperationId: parentOpId,
|
|
240
|
+
}).operationId;
|
|
241
|
+
|
|
242
|
+
// Complete the first child
|
|
243
|
+
result.current.completeOperation(completedChildOpId!);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Verify initial states
|
|
247
|
+
expect(result.current.operations[completedChildOpId!].status).toBe('completed');
|
|
248
|
+
expect(result.current.operations[runningChildOpId!].status).toBe('running');
|
|
249
|
+
|
|
250
|
+
act(() => {
|
|
251
|
+
result.current.cancelOperation(parentOpId!);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Parent and running child should be cancelled
|
|
255
|
+
expect(result.current.operations[parentOpId!].status).toBe('cancelled');
|
|
256
|
+
expect(result.current.operations[runningChildOpId!].status).toBe('cancelled');
|
|
257
|
+
|
|
258
|
+
// Completed child should remain completed (not cancelled)
|
|
259
|
+
expect(result.current.operations[completedChildOpId!].status).toBe('completed');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should not invoke cancel handler for already completed operations', async () => {
|
|
263
|
+
const { result } = renderHook(() => useChatStore());
|
|
264
|
+
|
|
265
|
+
let parentOpId: string;
|
|
266
|
+
let completedChildOpId: string;
|
|
267
|
+
const completedChildHandler = vi.fn();
|
|
268
|
+
|
|
269
|
+
act(() => {
|
|
270
|
+
parentOpId = result.current.startOperation({
|
|
271
|
+
type: 'execAgentRuntime',
|
|
272
|
+
context: { sessionId: 'session1' },
|
|
273
|
+
}).operationId;
|
|
274
|
+
|
|
275
|
+
completedChildOpId = result.current.startOperation({
|
|
276
|
+
type: 'toolCalling',
|
|
277
|
+
parentOperationId: parentOpId,
|
|
278
|
+
}).operationId;
|
|
279
|
+
|
|
280
|
+
// Register cancel handler
|
|
281
|
+
result.current.onOperationCancel(completedChildOpId!, completedChildHandler);
|
|
282
|
+
|
|
283
|
+
// Complete the child operation
|
|
284
|
+
result.current.completeOperation(completedChildOpId!);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
act(() => {
|
|
288
|
+
result.current.cancelOperation(parentOpId!);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Wait a bit to ensure no async handler calls
|
|
292
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
293
|
+
|
|
294
|
+
// Handler should NOT be called for completed operation
|
|
295
|
+
expect(completedChildHandler).not.toHaveBeenCalled();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should skip cancellation of already cancelled operations', () => {
|
|
299
|
+
const { result } = renderHook(() => useChatStore());
|
|
300
|
+
|
|
301
|
+
let operationId: string;
|
|
302
|
+
|
|
303
|
+
act(() => {
|
|
304
|
+
operationId = result.current.startOperation({
|
|
305
|
+
type: 'execAgentRuntime',
|
|
306
|
+
context: { sessionId: 'session1' },
|
|
307
|
+
}).operationId;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Cancel the operation
|
|
311
|
+
act(() => {
|
|
312
|
+
result.current.cancelOperation(operationId!, 'First cancellation');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(result.current.operations[operationId!].status).toBe('cancelled');
|
|
316
|
+
expect(result.current.operations[operationId!].metadata.cancelReason).toBe(
|
|
317
|
+
'First cancellation',
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Try to cancel again
|
|
321
|
+
act(() => {
|
|
322
|
+
result.current.cancelOperation(operationId!, 'Second cancellation');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Should still have the first cancellation reason (not updated)
|
|
326
|
+
expect(result.current.operations[operationId!].status).toBe('cancelled');
|
|
327
|
+
expect(result.current.operations[operationId!].metadata.cancelReason).toBe(
|
|
328
|
+
'First cancellation',
|
|
329
|
+
);
|
|
330
|
+
});
|
|
217
331
|
});
|
|
218
332
|
|
|
219
333
|
describe('failOperation', () => {
|
|
@@ -224,7 +338,7 @@ describe('Operation Actions', () => {
|
|
|
224
338
|
|
|
225
339
|
act(() => {
|
|
226
340
|
operationId = result.current.startOperation({
|
|
227
|
-
type: '
|
|
341
|
+
type: 'execAgentRuntime',
|
|
228
342
|
context: { sessionId: 'session1' },
|
|
229
343
|
}).operationId;
|
|
230
344
|
});
|
|
@@ -258,12 +372,12 @@ describe('Operation Actions', () => {
|
|
|
258
372
|
|
|
259
373
|
act(() => {
|
|
260
374
|
op1 = result.current.startOperation({
|
|
261
|
-
type: '
|
|
375
|
+
type: 'execAgentRuntime',
|
|
262
376
|
context: { sessionId: 'session1' },
|
|
263
377
|
}).operationId;
|
|
264
378
|
|
|
265
379
|
op2 = result.current.startOperation({
|
|
266
|
-
type: '
|
|
380
|
+
type: 'execAgentRuntime',
|
|
267
381
|
context: { sessionId: 'session1' },
|
|
268
382
|
}).operationId;
|
|
269
383
|
|
|
@@ -274,7 +388,7 @@ describe('Operation Actions', () => {
|
|
|
274
388
|
});
|
|
275
389
|
|
|
276
390
|
act(() => {
|
|
277
|
-
const cancelled = result.current.cancelOperations({ type: '
|
|
391
|
+
const cancelled = result.current.cancelOperations({ type: 'execAgentRuntime' });
|
|
278
392
|
expect(cancelled).toHaveLength(2);
|
|
279
393
|
});
|
|
280
394
|
|
|
@@ -284,6 +398,192 @@ describe('Operation Actions', () => {
|
|
|
284
398
|
});
|
|
285
399
|
});
|
|
286
400
|
|
|
401
|
+
describe('cleanupCompletedOperations', () => {
|
|
402
|
+
it('should remove operations completed longer than specified time', () => {
|
|
403
|
+
const { result } = renderHook(() => useChatStore());
|
|
404
|
+
|
|
405
|
+
let op1: string;
|
|
406
|
+
let op2: string;
|
|
407
|
+
let op3: string;
|
|
408
|
+
|
|
409
|
+
act(() => {
|
|
410
|
+
// Create and complete operations at different times
|
|
411
|
+
op1 = result.current.startOperation({
|
|
412
|
+
type: 'execAgentRuntime',
|
|
413
|
+
context: { sessionId: 'session1' },
|
|
414
|
+
}).operationId;
|
|
415
|
+
|
|
416
|
+
op2 = result.current.startOperation({
|
|
417
|
+
type: 'reasoning',
|
|
418
|
+
context: { sessionId: 'session1' },
|
|
419
|
+
}).operationId;
|
|
420
|
+
|
|
421
|
+
op3 = result.current.startOperation({
|
|
422
|
+
type: 'toolCalling',
|
|
423
|
+
context: { sessionId: 'session1' },
|
|
424
|
+
}).operationId;
|
|
425
|
+
|
|
426
|
+
// Complete op1 and op2, leave op3 running
|
|
427
|
+
result.current.completeOperation(op1!);
|
|
428
|
+
result.current.completeOperation(op2!);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Manually set endTime to simulate operations completed long ago
|
|
432
|
+
act(() => {
|
|
433
|
+
useChatStore.setState(
|
|
434
|
+
produce((state) => {
|
|
435
|
+
const now = Date.now();
|
|
436
|
+
if (state.operations[op1!]) {
|
|
437
|
+
state.operations[op1!].metadata.endTime = now - 70_000; // 70 seconds ago
|
|
438
|
+
}
|
|
439
|
+
if (state.operations[op2!]) {
|
|
440
|
+
state.operations[op2!].metadata.endTime = now - 20_000; // 20 seconds ago
|
|
441
|
+
}
|
|
442
|
+
}),
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Cleanup operations older than 60 seconds
|
|
447
|
+
let cleanedCount = 0;
|
|
448
|
+
act(() => {
|
|
449
|
+
cleanedCount = result.current.cleanupCompletedOperations(60_000);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
expect(cleanedCount).toBe(1);
|
|
453
|
+
expect(result.current.operations[op1!]).toBeUndefined(); // Removed (70s old)
|
|
454
|
+
expect(result.current.operations[op2!]).toBeDefined(); // Kept (20s old)
|
|
455
|
+
expect(result.current.operations[op3!]).toBeDefined(); // Kept (running)
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('should clean up operations on startOperation for top-level operations', () => {
|
|
459
|
+
const { result } = renderHook(() => useChatStore());
|
|
460
|
+
|
|
461
|
+
let completedOp: string;
|
|
462
|
+
|
|
463
|
+
act(() => {
|
|
464
|
+
// Create and complete an operation
|
|
465
|
+
completedOp = result.current.startOperation({
|
|
466
|
+
type: 'execAgentRuntime',
|
|
467
|
+
context: { sessionId: 'session1' },
|
|
468
|
+
}).operationId;
|
|
469
|
+
|
|
470
|
+
result.current.completeOperation(completedOp!);
|
|
471
|
+
|
|
472
|
+
// Set endTime to 40 seconds ago
|
|
473
|
+
useChatStore.setState(
|
|
474
|
+
produce((state) => {
|
|
475
|
+
if (state.operations[completedOp!]) {
|
|
476
|
+
state.operations[completedOp!].metadata.endTime = Date.now() - 40_000;
|
|
477
|
+
}
|
|
478
|
+
}),
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
expect(result.current.operations[completedOp!]).toBeDefined();
|
|
483
|
+
|
|
484
|
+
// Start a new top-level operation (should trigger cleanup)
|
|
485
|
+
act(() => {
|
|
486
|
+
result.current.startOperation({
|
|
487
|
+
type: 'execAgentRuntime',
|
|
488
|
+
context: { sessionId: 'session1' },
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Old operation should be cleaned up (older than 30s)
|
|
493
|
+
expect(result.current.operations[completedOp!]).toBeUndefined();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('should not clean up operations when starting child operations', () => {
|
|
497
|
+
const { result } = renderHook(() => useChatStore());
|
|
498
|
+
|
|
499
|
+
let parentOp: string;
|
|
500
|
+
let oldCompletedOp: string;
|
|
501
|
+
|
|
502
|
+
act(() => {
|
|
503
|
+
// Create parent operation
|
|
504
|
+
parentOp = result.current.startOperation({
|
|
505
|
+
type: 'execAgentRuntime',
|
|
506
|
+
context: { sessionId: 'session1' },
|
|
507
|
+
}).operationId;
|
|
508
|
+
|
|
509
|
+
// Create and complete an old operation
|
|
510
|
+
oldCompletedOp = result.current.startOperation({
|
|
511
|
+
type: 'reasoning',
|
|
512
|
+
context: { sessionId: 'session1' },
|
|
513
|
+
}).operationId;
|
|
514
|
+
|
|
515
|
+
result.current.completeOperation(oldCompletedOp!);
|
|
516
|
+
|
|
517
|
+
// Set endTime to 40 seconds ago
|
|
518
|
+
useChatStore.setState(
|
|
519
|
+
produce((state) => {
|
|
520
|
+
if (state.operations[oldCompletedOp!]) {
|
|
521
|
+
state.operations[oldCompletedOp!].metadata.endTime = Date.now() - 40_000;
|
|
522
|
+
}
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Start a child operation (should NOT trigger cleanup)
|
|
528
|
+
act(() => {
|
|
529
|
+
result.current.startOperation({
|
|
530
|
+
type: 'callLLM',
|
|
531
|
+
parentOperationId: parentOp!,
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// Old operation should still exist (cleanup not triggered for child operations)
|
|
536
|
+
expect(result.current.operations[oldCompletedOp!]).toBeDefined();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('should clean up cancelled and failed operations', () => {
|
|
540
|
+
const { result } = renderHook(() => useChatStore());
|
|
541
|
+
|
|
542
|
+
let cancelledOp: string;
|
|
543
|
+
let failedOp: string;
|
|
544
|
+
|
|
545
|
+
act(() => {
|
|
546
|
+
cancelledOp = result.current.startOperation({
|
|
547
|
+
type: 'execAgentRuntime',
|
|
548
|
+
context: { sessionId: 'session1' },
|
|
549
|
+
}).operationId;
|
|
550
|
+
|
|
551
|
+
failedOp = result.current.startOperation({
|
|
552
|
+
type: 'reasoning',
|
|
553
|
+
context: { sessionId: 'session1' },
|
|
554
|
+
}).operationId;
|
|
555
|
+
|
|
556
|
+
result.current.cancelOperation(cancelledOp!, 'User cancelled');
|
|
557
|
+
result.current.failOperation(failedOp!, {
|
|
558
|
+
type: 'Error',
|
|
559
|
+
message: 'Failed',
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// Set endTime to 70 seconds ago
|
|
563
|
+
useChatStore.setState(
|
|
564
|
+
produce((state) => {
|
|
565
|
+
const now = Date.now();
|
|
566
|
+
if (state.operations[cancelledOp!]) {
|
|
567
|
+
state.operations[cancelledOp!].metadata.endTime = now - 70_000;
|
|
568
|
+
}
|
|
569
|
+
if (state.operations[failedOp!]) {
|
|
570
|
+
state.operations[failedOp!].metadata.endTime = now - 70_000;
|
|
571
|
+
}
|
|
572
|
+
}),
|
|
573
|
+
);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
let cleanedCount = 0;
|
|
577
|
+
act(() => {
|
|
578
|
+
cleanedCount = result.current.cleanupCompletedOperations(60_000);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
expect(cleanedCount).toBe(2);
|
|
582
|
+
expect(result.current.operations[cancelledOp!]).toBeUndefined();
|
|
583
|
+
expect(result.current.operations[failedOp!]).toBeUndefined();
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
287
587
|
describe('associateMessageWithOperation', () => {
|
|
288
588
|
it('should create message-operation mapping', () => {
|
|
289
589
|
const { result } = renderHook(() => useChatStore());
|
|
@@ -292,7 +592,7 @@ describe('Operation Actions', () => {
|
|
|
292
592
|
|
|
293
593
|
act(() => {
|
|
294
594
|
operationId = result.current.startOperation({
|
|
295
|
-
type: '
|
|
595
|
+
type: 'execAgentRuntime',
|
|
296
596
|
context: { sessionId: 'session1' },
|
|
297
597
|
}).operationId;
|
|
298
598
|
|
|
@@ -300,6 +600,56 @@ describe('Operation Actions', () => {
|
|
|
300
600
|
});
|
|
301
601
|
|
|
302
602
|
expect(result.current.messageOperationMap.msg1).toBe(operationId!);
|
|
603
|
+
expect(result.current.operationsByMessage.msg1).toContain(operationId!);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('should update operationsByMessage index', () => {
|
|
607
|
+
const { result } = renderHook(() => useChatStore());
|
|
608
|
+
|
|
609
|
+
let op1: string;
|
|
610
|
+
let op2: string;
|
|
611
|
+
|
|
612
|
+
act(() => {
|
|
613
|
+
op1 = result.current.startOperation({
|
|
614
|
+
type: 'execAgentRuntime',
|
|
615
|
+
context: { sessionId: 'session1' },
|
|
616
|
+
}).operationId;
|
|
617
|
+
|
|
618
|
+
op2 = result.current.startOperation({
|
|
619
|
+
type: 'regenerate',
|
|
620
|
+
context: { sessionId: 'session1' },
|
|
621
|
+
}).operationId;
|
|
622
|
+
|
|
623
|
+
// Associate same message with multiple operations
|
|
624
|
+
result.current.associateMessageWithOperation('msg1', op1!);
|
|
625
|
+
result.current.associateMessageWithOperation('msg1', op2!);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// Both operations should be in the index
|
|
629
|
+
expect(result.current.operationsByMessage.msg1).toContain(op1!);
|
|
630
|
+
expect(result.current.operationsByMessage.msg1).toContain(op2!);
|
|
631
|
+
expect(result.current.operationsByMessage.msg1).toHaveLength(2);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('should not duplicate operation IDs in operationsByMessage', () => {
|
|
635
|
+
const { result } = renderHook(() => useChatStore());
|
|
636
|
+
|
|
637
|
+
let operationId: string;
|
|
638
|
+
|
|
639
|
+
act(() => {
|
|
640
|
+
operationId = result.current.startOperation({
|
|
641
|
+
type: 'execAgentRuntime',
|
|
642
|
+
context: { sessionId: 'session1' },
|
|
643
|
+
}).operationId;
|
|
644
|
+
|
|
645
|
+
// Associate same operation twice
|
|
646
|
+
result.current.associateMessageWithOperation('msg1', operationId!);
|
|
647
|
+
result.current.associateMessageWithOperation('msg1', operationId!);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// Should only appear once
|
|
651
|
+
expect(result.current.operationsByMessage.msg1).toHaveLength(1);
|
|
652
|
+
expect(result.current.operationsByMessage.msg1[0]).toBe(operationId!);
|
|
303
653
|
});
|
|
304
654
|
});
|
|
305
655
|
|
|
@@ -312,12 +662,12 @@ describe('Operation Actions', () => {
|
|
|
312
662
|
|
|
313
663
|
act(() => {
|
|
314
664
|
op1 = result.current.startOperation({
|
|
315
|
-
type: '
|
|
665
|
+
type: 'execAgentRuntime',
|
|
316
666
|
context: { sessionId: 'session1' },
|
|
317
667
|
}).operationId;
|
|
318
668
|
|
|
319
669
|
op2 = result.current.startOperation({
|
|
320
|
-
type: '
|
|
670
|
+
type: 'execAgentRuntime',
|
|
321
671
|
context: { sessionId: 'session1' },
|
|
322
672
|
}).operationId;
|
|
323
673
|
});
|
|
@@ -350,4 +700,350 @@ describe('Operation Actions', () => {
|
|
|
350
700
|
expect(result.current.operations[op2!]).toBeDefined(); // Still running
|
|
351
701
|
});
|
|
352
702
|
});
|
|
703
|
+
|
|
704
|
+
describe('getOperationAbortSignal', () => {
|
|
705
|
+
it('should return the AbortSignal for a given operation', () => {
|
|
706
|
+
const { result } = renderHook(() => useChatStore());
|
|
707
|
+
|
|
708
|
+
let operationId: string;
|
|
709
|
+
let abortController: AbortController;
|
|
710
|
+
|
|
711
|
+
act(() => {
|
|
712
|
+
const res = result.current.startOperation({
|
|
713
|
+
type: 'execAgentRuntime',
|
|
714
|
+
context: { sessionId: 'session1' },
|
|
715
|
+
});
|
|
716
|
+
operationId = res.operationId;
|
|
717
|
+
abortController = res.abortController;
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
const signal = result.current.getOperationAbortSignal(operationId!);
|
|
721
|
+
|
|
722
|
+
expect(signal).toBe(abortController!.signal);
|
|
723
|
+
expect(signal.aborted).toBe(false);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('should throw error when operation not found', () => {
|
|
727
|
+
const { result } = renderHook(() => useChatStore());
|
|
728
|
+
|
|
729
|
+
expect(() => {
|
|
730
|
+
result.current.getOperationAbortSignal('non-existent-id');
|
|
731
|
+
}).toThrow('Operation not found');
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it('should return aborted signal after operation is cancelled', () => {
|
|
735
|
+
const { result } = renderHook(() => useChatStore());
|
|
736
|
+
|
|
737
|
+
let operationId: string;
|
|
738
|
+
|
|
739
|
+
act(() => {
|
|
740
|
+
operationId = result.current.startOperation({
|
|
741
|
+
type: 'execAgentRuntime',
|
|
742
|
+
context: { sessionId: 'session1' },
|
|
743
|
+
}).operationId;
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
const signal = result.current.getOperationAbortSignal(operationId!);
|
|
747
|
+
|
|
748
|
+
expect(signal.aborted).toBe(false);
|
|
749
|
+
|
|
750
|
+
act(() => {
|
|
751
|
+
result.current.cancelOperation(operationId!);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
expect(signal.aborted).toBe(true);
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
describe('onOperationCancel', () => {
|
|
759
|
+
it('should register cancel handler for an operation', () => {
|
|
760
|
+
const { result } = renderHook(() => useChatStore());
|
|
761
|
+
|
|
762
|
+
let operationId: string;
|
|
763
|
+
const handler = vi.fn();
|
|
764
|
+
|
|
765
|
+
act(() => {
|
|
766
|
+
operationId = result.current.startOperation({
|
|
767
|
+
type: 'execAgentRuntime',
|
|
768
|
+
context: { sessionId: 'session1' },
|
|
769
|
+
}).operationId;
|
|
770
|
+
|
|
771
|
+
result.current.onOperationCancel(operationId!, handler);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
const operation = result.current.operations[operationId!];
|
|
775
|
+
expect(operation.onCancelHandler).toBe(handler);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('should handle registering handler for non-existent operation gracefully', () => {
|
|
779
|
+
const { result } = renderHook(() => useChatStore());
|
|
780
|
+
|
|
781
|
+
const handler = vi.fn();
|
|
782
|
+
|
|
783
|
+
act(() => {
|
|
784
|
+
result.current.onOperationCancel('non-existent-id', handler);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// Should not throw, just log warning
|
|
788
|
+
expect(handler).not.toHaveBeenCalled();
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
describe('cancelOperation with cancel handler', () => {
|
|
793
|
+
it('should call cancel handler when operation is cancelled', async () => {
|
|
794
|
+
const { result } = renderHook(() => useChatStore());
|
|
795
|
+
|
|
796
|
+
let operationId: string;
|
|
797
|
+
const handler = vi.fn();
|
|
798
|
+
|
|
799
|
+
act(() => {
|
|
800
|
+
operationId = result.current.startOperation({
|
|
801
|
+
type: 'createAssistantMessage',
|
|
802
|
+
context: { sessionId: 'session1', messageId: 'msg1' },
|
|
803
|
+
metadata: { tempMessageId: 'temp-123' },
|
|
804
|
+
}).operationId;
|
|
805
|
+
|
|
806
|
+
result.current.onOperationCancel(operationId!, handler);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
act(() => {
|
|
810
|
+
result.current.cancelOperation(operationId!, 'User clicked stop');
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
// Wait for async handler
|
|
814
|
+
await vi.waitFor(() => {
|
|
815
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
expect(handler).toHaveBeenCalledWith({
|
|
819
|
+
operationId: operationId!,
|
|
820
|
+
type: 'createAssistantMessage',
|
|
821
|
+
reason: 'User clicked stop',
|
|
822
|
+
metadata: expect.objectContaining({
|
|
823
|
+
tempMessageId: 'temp-123',
|
|
824
|
+
}),
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('should call handler with correct type for different operation types', async () => {
|
|
829
|
+
const { result } = renderHook(() => useChatStore());
|
|
830
|
+
|
|
831
|
+
const testCases = [
|
|
832
|
+
{ type: 'createAssistantMessage' as const, reason: 'Rollback creation' },
|
|
833
|
+
{ type: 'callLLM' as const, reason: 'LLM streaming cancelled' },
|
|
834
|
+
{ type: 'createToolMessage' as const, reason: 'Tool message creation cancelled' },
|
|
835
|
+
{ type: 'executeToolCall' as const, reason: 'Tool execution cancelled' },
|
|
836
|
+
];
|
|
837
|
+
|
|
838
|
+
for (const { type, reason } of testCases) {
|
|
839
|
+
const handler = vi.fn();
|
|
840
|
+
let opId: string;
|
|
841
|
+
|
|
842
|
+
act(() => {
|
|
843
|
+
opId = result.current.startOperation({
|
|
844
|
+
type,
|
|
845
|
+
context: { sessionId: 'session1' },
|
|
846
|
+
}).operationId;
|
|
847
|
+
|
|
848
|
+
result.current.onOperationCancel(opId!, handler);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
act(() => {
|
|
852
|
+
result.current.cancelOperation(opId!, reason);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
await vi.waitFor(() => {
|
|
856
|
+
expect(handler).toHaveBeenCalledWith(
|
|
857
|
+
expect.objectContaining({
|
|
858
|
+
type,
|
|
859
|
+
reason,
|
|
860
|
+
}),
|
|
861
|
+
);
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('should handle async cancel handler correctly', async () => {
|
|
867
|
+
const { result } = renderHook(() => useChatStore());
|
|
868
|
+
|
|
869
|
+
let operationId: string;
|
|
870
|
+
const asyncHandler = vi.fn(async ({ type }) => {
|
|
871
|
+
// Simulate async cleanup
|
|
872
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
873
|
+
// Don't return anything (void)
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
act(() => {
|
|
877
|
+
operationId = result.current.startOperation({
|
|
878
|
+
type: 'createToolMessage',
|
|
879
|
+
context: { sessionId: 'session1' },
|
|
880
|
+
}).operationId;
|
|
881
|
+
|
|
882
|
+
result.current.onOperationCancel(operationId!, asyncHandler);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
act(() => {
|
|
886
|
+
result.current.cancelOperation(operationId!);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// Handler should be called
|
|
890
|
+
await vi.waitFor(() => {
|
|
891
|
+
expect(asyncHandler).toHaveBeenCalledTimes(1);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
// Operation should be marked as cancelled even if handler is still running
|
|
895
|
+
expect(result.current.operations[operationId!].status).toBe('cancelled');
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it('should not block cancellation flow if handler throws error', async () => {
|
|
899
|
+
const { result } = renderHook(() => useChatStore());
|
|
900
|
+
|
|
901
|
+
let operationId: string;
|
|
902
|
+
const errorHandler = vi.fn(() => {
|
|
903
|
+
throw new Error('Handler error');
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
act(() => {
|
|
907
|
+
operationId = result.current.startOperation({
|
|
908
|
+
type: 'executeToolCall',
|
|
909
|
+
context: { sessionId: 'session1' },
|
|
910
|
+
}).operationId;
|
|
911
|
+
|
|
912
|
+
result.current.onOperationCancel(operationId!, errorHandler);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
act(() => {
|
|
916
|
+
result.current.cancelOperation(operationId!);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
// Operation should still be cancelled despite handler error
|
|
920
|
+
expect(result.current.operations[operationId!].status).toBe('cancelled');
|
|
921
|
+
expect(errorHandler).toHaveBeenCalledTimes(1);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it('should not call handler if operation is already cancelled', async () => {
|
|
925
|
+
const { result } = renderHook(() => useChatStore());
|
|
926
|
+
|
|
927
|
+
let operationId: string;
|
|
928
|
+
const handler = vi.fn();
|
|
929
|
+
|
|
930
|
+
act(() => {
|
|
931
|
+
operationId = result.current.startOperation({
|
|
932
|
+
type: 'callLLM',
|
|
933
|
+
context: { sessionId: 'session1' },
|
|
934
|
+
}).operationId;
|
|
935
|
+
|
|
936
|
+
result.current.onOperationCancel(operationId!, handler);
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
// Cancel first time
|
|
940
|
+
act(() => {
|
|
941
|
+
result.current.cancelOperation(operationId!);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
await vi.waitFor(() => {
|
|
945
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// Try to cancel again
|
|
949
|
+
act(() => {
|
|
950
|
+
result.current.cancelOperation(operationId!);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
// Handler should not be called again
|
|
954
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
955
|
+
});
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
describe('cancelOperation with child operations and handlers', () => {
|
|
959
|
+
it('should call handlers for both parent and child operations', async () => {
|
|
960
|
+
const { result } = renderHook(() => useChatStore());
|
|
961
|
+
|
|
962
|
+
let parentOpId: string;
|
|
963
|
+
let childOpId: string;
|
|
964
|
+
const parentHandler = vi.fn();
|
|
965
|
+
const childHandler = vi.fn();
|
|
966
|
+
|
|
967
|
+
act(() => {
|
|
968
|
+
parentOpId = result.current.startOperation({
|
|
969
|
+
type: 'execAgentRuntime',
|
|
970
|
+
context: { sessionId: 'session1' },
|
|
971
|
+
}).operationId;
|
|
972
|
+
|
|
973
|
+
childOpId = result.current.startOperation({
|
|
974
|
+
type: 'callLLM',
|
|
975
|
+
context: { messageId: 'msg1' },
|
|
976
|
+
parentOperationId: parentOpId!,
|
|
977
|
+
}).operationId;
|
|
978
|
+
|
|
979
|
+
result.current.onOperationCancel(parentOpId!, parentHandler);
|
|
980
|
+
result.current.onOperationCancel(childOpId!, childHandler);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
act(() => {
|
|
984
|
+
result.current.cancelOperation(parentOpId!);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
// Both handlers should be called
|
|
988
|
+
await vi.waitFor(() => {
|
|
989
|
+
expect(parentHandler).toHaveBeenCalledTimes(1);
|
|
990
|
+
expect(childHandler).toHaveBeenCalledTimes(1);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
// Child should be cancelled due to parent cancellation
|
|
994
|
+
expect(childHandler).toHaveBeenCalledWith(
|
|
995
|
+
expect.objectContaining({
|
|
996
|
+
operationId: childOpId!,
|
|
997
|
+
reason: 'Parent operation cancelled',
|
|
998
|
+
}),
|
|
999
|
+
);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it('should handle nested operations with multiple levels', async () => {
|
|
1003
|
+
const { result } = renderHook(() => useChatStore());
|
|
1004
|
+
|
|
1005
|
+
let level1: string;
|
|
1006
|
+
let level2: string;
|
|
1007
|
+
let level3: string;
|
|
1008
|
+
const handler1 = vi.fn();
|
|
1009
|
+
const handler2 = vi.fn();
|
|
1010
|
+
const handler3 = vi.fn();
|
|
1011
|
+
|
|
1012
|
+
act(() => {
|
|
1013
|
+
level1 = result.current.startOperation({
|
|
1014
|
+
type: 'execAgentRuntime',
|
|
1015
|
+
context: { sessionId: 'session1' },
|
|
1016
|
+
}).operationId;
|
|
1017
|
+
|
|
1018
|
+
level2 = result.current.startOperation({
|
|
1019
|
+
type: 'createAssistantMessage',
|
|
1020
|
+
parentOperationId: level1!,
|
|
1021
|
+
}).operationId;
|
|
1022
|
+
|
|
1023
|
+
level3 = result.current.startOperation({
|
|
1024
|
+
type: 'callLLM',
|
|
1025
|
+
parentOperationId: level2!,
|
|
1026
|
+
}).operationId;
|
|
1027
|
+
|
|
1028
|
+
result.current.onOperationCancel(level1!, handler1);
|
|
1029
|
+
result.current.onOperationCancel(level2!, handler2);
|
|
1030
|
+
result.current.onOperationCancel(level3!, handler3);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
act(() => {
|
|
1034
|
+
result.current.cancelOperation(level1!);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
await vi.waitFor(() => {
|
|
1038
|
+
expect(handler1).toHaveBeenCalledTimes(1);
|
|
1039
|
+
expect(handler2).toHaveBeenCalledTimes(1);
|
|
1040
|
+
expect(handler3).toHaveBeenCalledTimes(1);
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
// All operations should be cancelled
|
|
1044
|
+
expect(result.current.operations[level1!].status).toBe('cancelled');
|
|
1045
|
+
expect(result.current.operations[level2!].status).toBe('cancelled');
|
|
1046
|
+
expect(result.current.operations[level3!].status).toBe('cancelled');
|
|
1047
|
+
});
|
|
1048
|
+
});
|
|
353
1049
|
});
|