@lobehub/lobehub 2.0.0-next.85 → 2.0.0-next.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/apps/desktop/src/main/modules/networkProxy/dispatcher.ts +16 -16
  3. package/apps/desktop/src/main/modules/networkProxy/tester.ts +11 -11
  4. package/apps/desktop/src/main/modules/networkProxy/urlBuilder.ts +3 -3
  5. package/apps/desktop/src/main/modules/networkProxy/validator.ts +10 -10
  6. package/changelog/v1.json +18 -0
  7. package/package.json +1 -1
  8. package/packages/agent-runtime/src/core/runtime.ts +36 -1
  9. package/packages/agent-runtime/src/types/event.ts +1 -0
  10. package/packages/agent-runtime/src/types/generalAgent.ts +16 -0
  11. package/packages/agent-runtime/src/types/instruction.ts +30 -0
  12. package/packages/agent-runtime/src/types/runtime.ts +7 -0
  13. package/packages/types/src/message/common/metadata.ts +3 -0
  14. package/packages/types/src/message/common/tools.ts +2 -2
  15. package/packages/types/src/tool/search/index.ts +8 -2
  16. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/index.tsx +2 -2
  17. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/useSend.ts +7 -2
  18. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/useSend.ts +15 -14
  19. package/src/app/[variants]/(main)/chat/session/features/SessionListContent/List/Item/index.tsx +2 -2
  20. package/src/components/Analytics/MainInterfaceTracker.tsx +2 -2
  21. package/src/features/ChatInput/ActionBar/STT/browser.tsx +2 -2
  22. package/src/features/ChatInput/ActionBar/STT/openai.tsx +2 -2
  23. package/src/features/Conversation/MarkdownElements/LobeThinking/Render.tsx +3 -3
  24. package/src/features/Conversation/MarkdownElements/Thinking/Render.tsx +3 -3
  25. package/src/features/Conversation/Messages/Group/Tool/Inspector/index.tsx +1 -1
  26. package/src/features/Conversation/Messages/User/index.tsx +3 -3
  27. package/src/features/Conversation/Messages/index.tsx +3 -3
  28. package/src/features/Conversation/components/AutoScroll.tsx +2 -2
  29. package/src/features/PluginsUI/Render/StandaloneType/Iframe.tsx +3 -3
  30. package/src/features/Portal/Home/Body/Plugins/ArtifactList/index.tsx +3 -3
  31. package/src/features/ShareModal/ShareText/index.tsx +3 -3
  32. package/src/services/search.ts +2 -2
  33. package/src/store/chat/agents/GeneralChatAgent.ts +98 -0
  34. package/src/store/chat/agents/__tests__/GeneralChatAgent.test.ts +366 -0
  35. package/src/store/chat/agents/__tests__/createAgentExecutors/call-llm.test.ts +1217 -0
  36. package/src/store/chat/agents/__tests__/createAgentExecutors/call-tool.test.ts +1976 -0
  37. package/src/store/chat/agents/__tests__/createAgentExecutors/finish.test.ts +453 -0
  38. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/index.ts +4 -0
  39. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockInstructions.ts +126 -0
  40. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockMessages.ts +94 -0
  41. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockOperations.ts +96 -0
  42. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockStore.ts +138 -0
  43. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/assertions.ts +185 -0
  44. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/index.ts +3 -0
  45. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/operationTestUtils.ts +94 -0
  46. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/testExecutor.ts +139 -0
  47. package/src/store/chat/agents/__tests__/createAgentExecutors/request-human-approve.test.ts +545 -0
  48. package/src/store/chat/agents/__tests__/createAgentExecutors/resolve-aborted-tools.test.ts +686 -0
  49. package/src/store/chat/agents/createAgentExecutors.ts +313 -80
  50. package/src/store/chat/selectors.ts +1 -0
  51. package/src/store/chat/slices/aiChat/__tests__/ai-chat.integration.test.ts +667 -0
  52. package/src/store/chat/slices/aiChat/actions/__tests__/cancel-functionality.test.ts +137 -27
  53. package/src/store/chat/slices/aiChat/actions/__tests__/conversationControl.test.ts +163 -125
  54. package/src/store/chat/slices/aiChat/actions/__tests__/conversationLifecycle.test.ts +12 -2
  55. package/src/store/chat/slices/aiChat/actions/__tests__/fixtures.ts +0 -2
  56. package/src/store/chat/slices/aiChat/actions/__tests__/helpers.ts +0 -2
  57. package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +286 -19
  58. package/src/store/chat/slices/aiChat/actions/__tests__/streamingStates.test.ts +0 -112
  59. package/src/store/chat/slices/aiChat/actions/conversationControl.ts +42 -99
  60. package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +90 -57
  61. package/src/store/chat/slices/aiChat/actions/generateAIGroupChat.ts +5 -25
  62. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +220 -98
  63. package/src/store/chat/slices/aiChat/actions/streamingStates.ts +0 -34
  64. package/src/store/chat/slices/aiChat/initialState.ts +0 -28
  65. package/src/store/chat/slices/aiChat/selectors.test.ts +280 -0
  66. package/src/store/chat/slices/aiChat/selectors.ts +31 -7
  67. package/src/store/chat/slices/builtinTool/actions/__tests__/localSystem.test.ts +21 -30
  68. package/src/store/chat/slices/builtinTool/actions/__tests__/search.test.ts +29 -49
  69. package/src/store/chat/slices/builtinTool/actions/interpreter.ts +83 -48
  70. package/src/store/chat/slices/builtinTool/actions/localSystem.ts +78 -28
  71. package/src/store/chat/slices/builtinTool/actions/search.ts +146 -59
  72. package/src/store/chat/slices/builtinTool/selectors.test.ts +258 -0
  73. package/src/store/chat/slices/builtinTool/selectors.ts +25 -4
  74. package/src/store/chat/slices/message/action.test.ts +134 -16
  75. package/src/store/chat/slices/message/actions/internals.ts +33 -7
  76. package/src/store/chat/slices/message/actions/optimisticUpdate.ts +85 -52
  77. package/src/store/chat/slices/message/initialState.ts +0 -10
  78. package/src/store/chat/slices/message/selectors/messageState.ts +34 -12
  79. package/src/store/chat/slices/operation/__tests__/actions.test.ts +712 -16
  80. package/src/store/chat/slices/operation/__tests__/integration.test.ts +342 -0
  81. package/src/store/chat/slices/operation/__tests__/selectors.test.ts +257 -17
  82. package/src/store/chat/slices/operation/actions.ts +218 -11
  83. package/src/store/chat/slices/operation/selectors.ts +135 -6
  84. package/src/store/chat/slices/operation/types.ts +29 -3
  85. package/src/store/chat/slices/plugin/action.test.ts +30 -322
  86. package/src/store/chat/slices/plugin/actions/internals.ts +0 -14
  87. package/src/store/chat/slices/plugin/actions/optimisticUpdate.ts +21 -19
  88. package/src/store/chat/slices/plugin/actions/pluginTypes.ts +45 -27
  89. package/src/store/chat/slices/plugin/actions/publicApi.ts +3 -4
  90. package/src/store/chat/slices/plugin/actions/workflow.ts +0 -55
  91. package/src/store/chat/slices/thread/selectors/index.ts +4 -2
  92. package/src/store/chat/slices/topic/action.ts +3 -3
  93. package/src/store/chat/slices/translate/action.ts +54 -41
  94. package/src/tools/web-browsing/ExecutionRuntime/index.ts +5 -2
  95. 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: 'generateAI',
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('generateAI');
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: 'generateAI',
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: 'generateAI',
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: 'generateAI',
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.generateAI).toContain(operationId!);
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: 'generateAI',
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: 'generateAI',
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: 'generateAI',
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: 'generateAI',
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: 'generateAI',
375
+ type: 'execAgentRuntime',
262
376
  context: { sessionId: 'session1' },
263
377
  }).operationId;
264
378
 
265
379
  op2 = result.current.startOperation({
266
- type: 'generateAI',
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: 'generateAI' });
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: 'generateAI',
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: 'generateAI',
665
+ type: 'execAgentRuntime',
316
666
  context: { sessionId: 'session1' },
317
667
  }).operationId;
318
668
 
319
669
  op2 = result.current.startOperation({
320
- type: 'generateAI',
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
  });