@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
@@ -0,0 +1,667 @@
1
+ /**
2
+ * Integration test for AI Chat with Operation Management System
3
+ * Tests the integration between AI chat actions and the unified operation system
4
+ */
5
+ import { act, renderHook } from '@testing-library/react';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+
8
+ import { operationSelectors } from '@/store/chat/selectors';
9
+ import { useChatStore } from '@/store/chat/store';
10
+ import { messageMapKey } from '@/store/chat/utils/messageMapKey';
11
+
12
+ vi.mock('zustand/traditional');
13
+
14
+ describe('AI Chat Operation Integration Tests', () => {
15
+ beforeEach(() => {
16
+ act(() => {
17
+ useChatStore.setState({
18
+ activeId: 'test-session',
19
+ activeTopicId: 'test-topic',
20
+ operations: {},
21
+ operationsByType: {} as any,
22
+ operationsByMessage: {},
23
+ operationsByContext: {},
24
+ messageOperationMap: {},
25
+ mainInputEditor: undefined,
26
+ });
27
+ });
28
+ });
29
+
30
+ describe('SendMessage Operation Lifecycle', () => {
31
+ it('should create sendMessage operation with editor state', () => {
32
+ const { result } = renderHook(() => useChatStore());
33
+ const sessionId = 'test-session';
34
+ const topicId = 'test-topic';
35
+
36
+ const mockEditorState = { type: 'doc', content: [{ type: 'text', text: 'Hello' }] };
37
+
38
+ let operationId: string;
39
+ act(() => {
40
+ const { operationId: id } = result.current.startOperation({
41
+ type: 'sendMessage',
42
+ context: { sessionId, topicId },
43
+ metadata: {
44
+ inputEditorTempState: mockEditorState,
45
+ },
46
+ });
47
+ operationId = id;
48
+ });
49
+
50
+ const operation = result.current.operations[operationId!];
51
+ expect(operation).toBeDefined();
52
+ expect(operation.type).toBe('sendMessage');
53
+ expect(operation.status).toBe('running');
54
+ expect(operation.metadata.inputEditorTempState).toEqual(mockEditorState);
55
+ });
56
+
57
+ it('should restore editor state when cancelling sendMessage', () => {
58
+ const { result } = renderHook(() => useChatStore());
59
+ const sessionId = 'test-session';
60
+ const topicId = 'test-topic';
61
+
62
+ const mockEditorState = { type: 'doc', content: [{ type: 'text', text: 'Hello World' }] };
63
+ const mockEditor = {
64
+ setJSONState: vi.fn(),
65
+ };
66
+
67
+ // Set mock editor
68
+ act(() => {
69
+ useChatStore.setState({ mainInputEditor: mockEditor as any });
70
+ });
71
+
72
+ let operationId: string;
73
+ act(() => {
74
+ const { operationId: id } = result.current.startOperation({
75
+ type: 'sendMessage',
76
+ context: { sessionId, topicId },
77
+ metadata: {
78
+ inputEditorTempState: mockEditorState,
79
+ },
80
+ });
81
+ operationId = id;
82
+ });
83
+
84
+ // Cancel operation
85
+ act(() => {
86
+ result.current.cancelOperation(operationId!, 'User cancelled');
87
+ });
88
+
89
+ // Verify operation cancelled
90
+ expect(result.current.operations[operationId!].status).toBe('cancelled');
91
+ expect(result.current.operations[operationId!].metadata.cancelReason).toBe('User cancelled');
92
+ });
93
+
94
+ it('should handle error message in sendMessage operation', () => {
95
+ const { result } = renderHook(() => useChatStore());
96
+ const sessionId = 'test-session';
97
+ const topicId = 'test-topic';
98
+
99
+ let operationId: string;
100
+ act(() => {
101
+ const { operationId: id } = result.current.startOperation({
102
+ type: 'sendMessage',
103
+ context: { sessionId, topicId },
104
+ });
105
+ operationId = id;
106
+ });
107
+
108
+ // Set error message
109
+ const errorMsg = 'Failed to send message: Network error';
110
+ act(() => {
111
+ result.current.updateOperationMetadata(operationId!, {
112
+ inputSendErrorMsg: errorMsg,
113
+ });
114
+ });
115
+
116
+ // Verify error message stored
117
+ expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBe(errorMsg);
118
+
119
+ // Clear error message
120
+ act(() => {
121
+ result.current.updateOperationMetadata(operationId!, {
122
+ inputSendErrorMsg: undefined,
123
+ });
124
+ });
125
+
126
+ // Verify error message cleared
127
+ expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBeUndefined();
128
+ });
129
+
130
+ it('should handle abort controller for sendMessage operation', () => {
131
+ const { result } = renderHook(() => useChatStore());
132
+
133
+ let operationId = '';
134
+ let abortController: AbortController | undefined;
135
+
136
+ act(() => {
137
+ const res = result.current.startOperation({
138
+ type: 'sendMessage',
139
+ context: { sessionId: 'test-session', topicId: 'test-topic' },
140
+ });
141
+ operationId = res.operationId;
142
+ abortController = res.abortController;
143
+ });
144
+
145
+ expect(abortController!.signal.aborted).toBe(false);
146
+
147
+ // Cancel operation
148
+ act(() => {
149
+ result.current.cancelOperation(operationId, 'User stopped');
150
+ });
151
+
152
+ expect(abortController!.signal.aborted).toBe(true);
153
+ expect(result.current.operations[operationId].status).toBe('cancelled');
154
+ });
155
+ });
156
+
157
+ describe('AI Generation Operation Integration', () => {
158
+ it('should create generateAI operation and associate with message', () => {
159
+ const { result } = renderHook(() => useChatStore());
160
+ const sessionId = 'session-1';
161
+ const topicId = 'topic-1';
162
+ const messageId = 'msg-1';
163
+
164
+ let operationId = '';
165
+ act(() => {
166
+ const { operationId: id } = result.current.startOperation({
167
+ type: 'execAgentRuntime',
168
+ context: { sessionId, topicId, messageId },
169
+ label: 'AI Generation',
170
+ });
171
+ operationId = id;
172
+ });
173
+
174
+ // Associate message
175
+ act(() => {
176
+ result.current.associateMessageWithOperation(messageId, operationId);
177
+ });
178
+
179
+ // Verify operation and association
180
+ expect(result.current.operations[operationId]).toBeDefined();
181
+ expect(result.current.messageOperationMap[messageId]).toBe(operationId);
182
+ expect(operationSelectors.isAIGenerating(result.current)).toBe(true);
183
+ });
184
+
185
+ it('should handle AI generation with child operations (reasoning, toolCalling, rag)', () => {
186
+ const { result } = renderHook(() => useChatStore());
187
+
188
+ // Create parent generateAI operation
189
+ let parentOpId = '';
190
+ act(() => {
191
+ const { operationId } = result.current.startOperation({
192
+ type: 'execAgentRuntime',
193
+ context: { sessionId: 'session-1', topicId: 'topic-1', messageId: 'msg-1' },
194
+ });
195
+ parentOpId = operationId;
196
+ });
197
+
198
+ // Create child operations without explicit context (should inherit)
199
+ let reasoningOpId = '';
200
+ let ragOpId = '';
201
+ let toolCallingOpId = '';
202
+
203
+ act(() => {
204
+ reasoningOpId = result.current.startOperation({
205
+ type: 'reasoning',
206
+ parentOperationId: parentOpId,
207
+ }).operationId;
208
+
209
+ ragOpId = result.current.startOperation({
210
+ type: 'rag',
211
+ parentOperationId: parentOpId,
212
+ }).operationId;
213
+
214
+ toolCallingOpId = result.current.startOperation({
215
+ type: 'toolCalling',
216
+ parentOperationId: parentOpId,
217
+ }).operationId;
218
+ });
219
+
220
+ // Verify all child operations inherited parent context
221
+ const parentContext = result.current.operations[parentOpId].context;
222
+ expect(result.current.operations[reasoningOpId].context).toEqual(parentContext);
223
+ expect(result.current.operations[ragOpId].context).toEqual(parentContext);
224
+ expect(result.current.operations[toolCallingOpId].context).toEqual(parentContext);
225
+
226
+ // Verify parent-child relationships
227
+ const parent = result.current.operations[parentOpId];
228
+ expect(parent.childOperationIds).toContain(reasoningOpId);
229
+ expect(parent.childOperationIds).toContain(ragOpId);
230
+ expect(parent.childOperationIds).toContain(toolCallingOpId);
231
+
232
+ // Verify all operations are running
233
+ expect(operationSelectors.getRunningOperations(result.current)).toHaveLength(4);
234
+ });
235
+
236
+ it('should cancel all child operations when parent AI generation is cancelled', () => {
237
+ const { result } = renderHook(() => useChatStore());
238
+
239
+ // Create complex operation hierarchy (AI generation -> tool calling -> plugin API)
240
+ let parentOpId = '';
241
+ let reasoningOpId = '';
242
+ let toolCallingOpId = '';
243
+ let pluginApiOpId = '';
244
+
245
+ act(() => {
246
+ parentOpId = result.current.startOperation({
247
+ type: 'execAgentRuntime',
248
+ context: { sessionId: 'session-1', messageId: 'msg-1' },
249
+ }).operationId;
250
+
251
+ reasoningOpId = result.current.startOperation({
252
+ type: 'reasoning',
253
+ parentOperationId: parentOpId,
254
+ }).operationId;
255
+
256
+ toolCallingOpId = result.current.startOperation({
257
+ type: 'toolCalling',
258
+ parentOperationId: parentOpId,
259
+ }).operationId;
260
+
261
+ // Create grandchild operation
262
+ pluginApiOpId = result.current.startOperation({
263
+ type: 'pluginApi',
264
+ parentOperationId: toolCallingOpId,
265
+ }).operationId;
266
+ });
267
+
268
+ // Cancel parent AI generation
269
+ act(() => {
270
+ result.current.cancelOperation(parentOpId, 'User stopped generation');
271
+ });
272
+
273
+ // Verify entire hierarchy is cancelled
274
+ expect(result.current.operations[parentOpId].status).toBe('cancelled');
275
+ expect(result.current.operations[reasoningOpId].status).toBe('cancelled');
276
+ expect(result.current.operations[toolCallingOpId].status).toBe('cancelled');
277
+ expect(result.current.operations[pluginApiOpId].status).toBe('cancelled');
278
+
279
+ // Verify no running operations
280
+ expect(operationSelectors.hasAnyRunningOperation(result.current)).toBe(false);
281
+ expect(operationSelectors.canSendMessage(result.current)).toBe(true);
282
+ });
283
+
284
+ it('should complete AI generation and all child operations', () => {
285
+ const { result } = renderHook(() => useChatStore());
286
+
287
+ let parentOpId = '';
288
+ let childOpId = '';
289
+
290
+ act(() => {
291
+ parentOpId = result.current.startOperation({
292
+ type: 'execAgentRuntime',
293
+ context: { sessionId: 'session-1' },
294
+ }).operationId;
295
+
296
+ childOpId = result.current.startOperation({
297
+ type: 'toolCalling',
298
+ parentOperationId: parentOpId,
299
+ }).operationId;
300
+ });
301
+
302
+ // Complete child first
303
+ act(() => {
304
+ result.current.completeOperation(childOpId);
305
+ });
306
+
307
+ expect(result.current.operations[childOpId].status).toBe('completed');
308
+ expect(result.current.operations[parentOpId].status).toBe('running');
309
+
310
+ // Complete parent
311
+ act(() => {
312
+ result.current.completeOperation(parentOpId);
313
+ });
314
+
315
+ expect(result.current.operations[parentOpId].status).toBe('completed');
316
+ expect(result.current.operations[parentOpId].metadata.duration).toBeGreaterThanOrEqual(0);
317
+ expect(operationSelectors.canSendMessage(result.current)).toBe(true);
318
+ });
319
+ });
320
+
321
+ describe('Multi-Context Operation Isolation', () => {
322
+ it('should handle multiple sendMessage operations in different contexts', () => {
323
+ const { result } = renderHook(() => useChatStore());
324
+
325
+ const op1Context = { sessionId: 'session-1', topicId: 'topic-a' };
326
+ const op2Context = { sessionId: 'session-1', topicId: 'topic-b' };
327
+ const op3Context = { sessionId: 'session-2', topicId: 'topic-a' };
328
+
329
+ let op1Id = '';
330
+ let op2Id = '';
331
+ let op3Id = '';
332
+
333
+ act(() => {
334
+ op1Id = result.current.startOperation({
335
+ type: 'sendMessage',
336
+ context: op1Context,
337
+ }).operationId;
338
+
339
+ op2Id = result.current.startOperation({
340
+ type: 'sendMessage',
341
+ context: op2Context,
342
+ }).operationId;
343
+
344
+ op3Id = result.current.startOperation({
345
+ type: 'sendMessage',
346
+ context: op3Context,
347
+ }).operationId;
348
+ });
349
+
350
+ // Verify all operations created with correct contexts
351
+ expect(result.current.operations[op1Id].context).toMatchObject(op1Context);
352
+ expect(result.current.operations[op2Id].context).toMatchObject(op2Context);
353
+ expect(result.current.operations[op3Id].context).toMatchObject(op3Context);
354
+
355
+ // Verify context index
356
+ const contextKey1 = messageMapKey('session-1', 'topic-a');
357
+ const contextKey2 = messageMapKey('session-1', 'topic-b');
358
+ const contextKey3 = messageMapKey('session-2', 'topic-a');
359
+
360
+ expect(result.current.operationsByContext[contextKey1]).toContain(op1Id);
361
+ expect(result.current.operationsByContext[contextKey2]).toContain(op2Id);
362
+ expect(result.current.operationsByContext[contextKey3]).toContain(op3Id);
363
+ });
364
+
365
+ it('should cancel operations only in specific topic', () => {
366
+ const { result } = renderHook(() => useChatStore());
367
+
368
+ let topicAOpId = '';
369
+ let topicBOpId = '';
370
+
371
+ act(() => {
372
+ topicAOpId = result.current.startOperation({
373
+ type: 'execAgentRuntime',
374
+ context: { sessionId: 'session-1', topicId: 'topic-a' },
375
+ }).operationId;
376
+
377
+ topicBOpId = result.current.startOperation({
378
+ type: 'execAgentRuntime',
379
+ context: { sessionId: 'session-1', topicId: 'topic-b' },
380
+ }).operationId;
381
+ });
382
+
383
+ // Cancel operations in topic-a only
384
+ let cancelledIds: string[] = [];
385
+ act(() => {
386
+ cancelledIds = result.current.cancelOperations({
387
+ sessionId: 'session-1',
388
+ topicId: 'topic-a',
389
+ });
390
+ });
391
+
392
+ // Verify only topic-a operations cancelled
393
+ expect(cancelledIds).toHaveLength(1);
394
+ expect(cancelledIds).toContain(topicAOpId);
395
+ expect(result.current.operations[topicAOpId].status).toBe('cancelled');
396
+ expect(result.current.operations[topicBOpId].status).toBe('running');
397
+ });
398
+
399
+ it('should isolate operations between different sessions', () => {
400
+ const { result } = renderHook(() => useChatStore());
401
+
402
+ let session1OpId = '';
403
+ let session2OpId = '';
404
+
405
+ act(() => {
406
+ session1OpId = result.current.startOperation({
407
+ type: 'execAgentRuntime',
408
+ context: { sessionId: 'session-1', topicId: 'topic-1' },
409
+ }).operationId;
410
+
411
+ session2OpId = result.current.startOperation({
412
+ type: 'execAgentRuntime',
413
+ context: { sessionId: 'session-2', topicId: 'topic-1' },
414
+ }).operationId;
415
+ });
416
+
417
+ // Cancel operations in session-1 only
418
+ let cancelledIds: string[] = [];
419
+ act(() => {
420
+ cancelledIds = result.current.cancelOperations({
421
+ sessionId: 'session-1',
422
+ });
423
+ });
424
+
425
+ // Verify only session-1 operations cancelled
426
+ expect(cancelledIds).toHaveLength(1);
427
+ expect(cancelledIds).toContain(session1OpId);
428
+ expect(result.current.operations[session1OpId].status).toBe('cancelled');
429
+ expect(result.current.operations[session2OpId].status).toBe('running');
430
+ });
431
+ });
432
+
433
+ describe('Operation Error Handling', () => {
434
+ it('should handle operation failure with error details', () => {
435
+ const { result } = renderHook(() => useChatStore());
436
+
437
+ let operationId = '';
438
+ act(() => {
439
+ const { operationId: id } = result.current.startOperation({
440
+ type: 'execAgentRuntime',
441
+ context: { sessionId: 'session-1' },
442
+ });
443
+ operationId = id;
444
+ });
445
+
446
+ // Fail operation with error
447
+ const error = {
448
+ type: 'NetworkError',
449
+ message: 'Failed to connect to AI service',
450
+ code: 'ERR_NETWORK',
451
+ details: { statusCode: 503 },
452
+ };
453
+
454
+ act(() => {
455
+ result.current.failOperation(operationId, error);
456
+ });
457
+
458
+ const operation = result.current.operations[operationId];
459
+ expect(operation.status).toBe('failed');
460
+ expect(operation.metadata.error).toEqual(error);
461
+ expect(operation.metadata.endTime).toBeDefined();
462
+ expect(operation.metadata.duration).toBeGreaterThanOrEqual(0);
463
+ });
464
+
465
+ it('should handle sendMessage error display', () => {
466
+ const { result } = renderHook(() => useChatStore());
467
+ const sessionId = 'session-1';
468
+ const topicId = 'topic-1';
469
+
470
+ let operationId = '';
471
+ act(() => {
472
+ const { operationId: id } = result.current.startOperation({
473
+ type: 'sendMessage',
474
+ context: { sessionId, topicId },
475
+ });
476
+ operationId = id;
477
+ });
478
+
479
+ // Set error message for UI display
480
+ const errorMsg = 'Message too long';
481
+ act(() => {
482
+ result.current.updateOperationMetadata(operationId, {
483
+ inputSendErrorMsg: errorMsg,
484
+ });
485
+ });
486
+
487
+ // Verify error message can be retrieved
488
+ expect(result.current.operations[operationId].metadata.inputSendErrorMsg).toBe(errorMsg);
489
+
490
+ // User fixes the error and clears it
491
+ act(() => {
492
+ result.current.updateOperationMetadata(operationId, {
493
+ inputSendErrorMsg: undefined,
494
+ });
495
+ });
496
+
497
+ expect(result.current.operations[operationId].metadata.inputSendErrorMsg).toBeUndefined();
498
+ });
499
+ });
500
+
501
+ describe('Tool Execution Cancellation', () => {
502
+ it('should abort tool execution when executeToolCall operation is cancelled', () => {
503
+ const { result } = renderHook(() => useChatStore());
504
+ const messageId = 'tool-msg-1';
505
+
506
+ // Create toolCalling parent operation
507
+ let toolCallingOpId = '';
508
+ act(() => {
509
+ toolCallingOpId = result.current.startOperation({
510
+ type: 'toolCalling',
511
+ context: { sessionId: 'session-1', messageId },
512
+ }).operationId;
513
+ });
514
+
515
+ // Create executeToolCall child operation
516
+ let executeToolOpId = '';
517
+ let executeToolAbortController: AbortController | undefined;
518
+ act(() => {
519
+ const res = result.current.startOperation({
520
+ type: 'executeToolCall',
521
+ context: { sessionId: 'session-1', messageId },
522
+ parentOperationId: toolCallingOpId,
523
+ });
524
+ executeToolOpId = res.operationId;
525
+ executeToolAbortController = res.abortController;
526
+ });
527
+
528
+ // Associate message with executeToolCall operation (not parent)
529
+ act(() => {
530
+ result.current.associateMessageWithOperation(messageId, executeToolOpId);
531
+ });
532
+
533
+ // Verify message is associated with executeToolCall operation
534
+ expect(result.current.messageOperationMap[messageId]).toBe(executeToolOpId);
535
+
536
+ // Verify abort signal is not aborted yet
537
+ expect(executeToolAbortController!.signal.aborted).toBe(false);
538
+
539
+ // Cancel parent toolCalling operation (should cascade to child)
540
+ act(() => {
541
+ result.current.cancelOperation(toolCallingOpId, 'User stopped');
542
+ });
543
+
544
+ // Verify both operations are cancelled
545
+ expect(result.current.operations[toolCallingOpId].status).toBe('cancelled');
546
+ expect(result.current.operations[executeToolOpId].status).toBe('cancelled');
547
+
548
+ // Verify abort signal is triggered
549
+ expect(executeToolAbortController!.signal.aborted).toBe(true);
550
+
551
+ // Verify tool can check abort status via messageOperationMap
552
+ const toolOperation =
553
+ result.current.operations[result.current.messageOperationMap[messageId]];
554
+ expect(toolOperation.status).toBe('cancelled');
555
+ expect(toolOperation.abortController.signal.aborted).toBe(true);
556
+ });
557
+
558
+ it('should allow tool execution to check abort signal before starting', () => {
559
+ const { result } = renderHook(() => useChatStore());
560
+ const messageId = 'tool-msg-2';
561
+
562
+ // Create and immediately cancel executeToolCall operation
563
+ let executeToolOpId = '';
564
+ let abortController: AbortController | undefined;
565
+
566
+ act(() => {
567
+ const res = result.current.startOperation({
568
+ type: 'executeToolCall',
569
+ context: { sessionId: 'session-1', messageId },
570
+ });
571
+ executeToolOpId = res.operationId;
572
+ abortController = res.abortController;
573
+ });
574
+
575
+ // Associate message
576
+ act(() => {
577
+ result.current.associateMessageWithOperation(messageId, executeToolOpId);
578
+ });
579
+
580
+ // Cancel immediately
581
+ act(() => {
582
+ result.current.cancelOperation(executeToolOpId, 'Cancelled before execution');
583
+ });
584
+
585
+ // Simulate tool checking abort signal before execution
586
+ const operationId = result.current.messageOperationMap[messageId];
587
+ const operation = operationId ? result.current.operations[operationId] : undefined;
588
+ const toolAbortController = operation?.abortController;
589
+
590
+ // Tool should detect cancellation
591
+ expect(toolAbortController?.signal.aborted).toBe(true);
592
+ expect(operation?.status).toBe('cancelled');
593
+ });
594
+ });
595
+
596
+ describe('Operation State Queries', () => {
597
+ it('should correctly report AI generation state', () => {
598
+ const { result } = renderHook(() => useChatStore());
599
+
600
+ // Initially no AI generation
601
+ expect(operationSelectors.isAIGenerating(result.current)).toBe(false);
602
+ expect(operationSelectors.canSendMessage(result.current)).toBe(true);
603
+
604
+ // Start AI generation in current context
605
+ let operationId = '';
606
+ act(() => {
607
+ const { operationId: id } = result.current.startOperation({
608
+ type: 'execAgentRuntime',
609
+ context: {
610
+ sessionId: result.current.activeId,
611
+ topicId: result.current.activeTopicId,
612
+ },
613
+ });
614
+ operationId = id;
615
+ });
616
+
617
+ expect(operationSelectors.isAIGenerating(result.current)).toBe(true);
618
+ expect(operationSelectors.canSendMessage(result.current)).toBe(false);
619
+
620
+ // Complete generation
621
+ act(() => {
622
+ result.current.completeOperation(operationId);
623
+ });
624
+
625
+ expect(operationSelectors.isAIGenerating(result.current)).toBe(false);
626
+ expect(operationSelectors.canSendMessage(result.current)).toBe(true);
627
+ });
628
+
629
+ it('should report running operations by type', () => {
630
+ const { result } = renderHook(() => useChatStore());
631
+
632
+ let sendOpId = '';
633
+ let genOpId1 = '';
634
+ let genOpId2 = '';
635
+
636
+ act(() => {
637
+ sendOpId = result.current.startOperation({
638
+ type: 'sendMessage',
639
+ context: { sessionId: 'session-1' },
640
+ }).operationId;
641
+
642
+ genOpId1 = result.current.startOperation({
643
+ type: 'execAgentRuntime',
644
+ context: { sessionId: 'session-1' },
645
+ }).operationId;
646
+
647
+ genOpId2 = result.current.startOperation({
648
+ type: 'execAgentRuntime',
649
+ context: { sessionId: 'session-2' },
650
+ }).operationId;
651
+ });
652
+
653
+ // Verify type index
654
+ expect(result.current.operationsByType.sendMessage).toContain(sendOpId);
655
+ expect(result.current.operationsByType.execAgentRuntime).toContain(genOpId1);
656
+ expect(result.current.operationsByType.execAgentRuntime).toContain(genOpId2);
657
+
658
+ // Complete one generateAI
659
+ act(() => {
660
+ result.current.completeOperation(genOpId1);
661
+ });
662
+
663
+ // Verify AI still generating (genOpId2 is still running)
664
+ expect(operationSelectors.isAIGenerating(result.current)).toBe(true);
665
+ });
666
+ });
667
+ });