@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,7 +1,8 @@
1
1
  import { act, renderHook } from '@testing-library/react';
2
- import { describe, expect, it, vi } from 'vitest';
2
+ import { describe, expect, it } from 'vitest';
3
3
 
4
4
  import { useChatStore } from '../../../../store';
5
+ import { messageMapKey } from '../../../../utils/messageMapKey';
5
6
 
6
7
  describe('Cancel send message functionality tests', () => {
7
8
  describe('cancelSendMessageInServer', () => {
@@ -13,7 +14,8 @@ describe('Cancel send message functionality tests', () => {
13
14
  useChatStore.setState({
14
15
  activeId: 'session-1',
15
16
  activeTopicId: 'topic-1',
16
- mainSendMessageOperations: {},
17
+ operations: {},
18
+ operationsByContext: {},
17
19
  });
18
20
  });
19
21
 
@@ -28,13 +30,91 @@ describe('Cancel send message functionality tests', () => {
28
30
  }).not.toThrow();
29
31
  });
30
32
 
33
+ it('should cancel running sendMessage operations', () => {
34
+ const { result } = renderHook(() => useChatStore());
35
+
36
+ const sessionId = 'session-1';
37
+ const topicId = 'topic-1';
38
+
39
+ act(() => {
40
+ useChatStore.setState({
41
+ activeId: sessionId,
42
+ activeTopicId: topicId,
43
+ });
44
+ });
45
+
46
+ // Start a sendMessage operation
47
+ let operationId: string;
48
+ act(() => {
49
+ const res = result.current.startOperation({
50
+ type: 'sendMessage',
51
+ context: { sessionId, topicId },
52
+ });
53
+ operationId = res.operationId;
54
+ });
55
+
56
+ expect(result.current.operations[operationId!].status).toBe('running');
57
+
58
+ // Cancel the operation
59
+ act(() => {
60
+ result.current.cancelSendMessageInServer();
61
+ });
62
+
63
+ expect(result.current.operations[operationId!].status).toBe('cancelled');
64
+ });
65
+
66
+ it('should restore editor state when cancelling', () => {
67
+ const { result } = renderHook(() => useChatStore());
68
+
69
+ const sessionId = 'session-1';
70
+ const topicId = 'topic-1';
71
+ const mockEditorState = { content: 'test message' };
72
+
73
+ // Mock editor
74
+ const mockEditor = {
75
+ setJSONState: vi.fn(),
76
+ getJSONState: vi.fn().mockReturnValue(mockEditorState),
77
+ };
78
+
79
+ act(() => {
80
+ useChatStore.setState({
81
+ activeId: sessionId,
82
+ activeTopicId: topicId,
83
+ mainInputEditor: mockEditor as any,
84
+ });
85
+ });
86
+
87
+ // Create operation with editor state
88
+ let operationId: string;
89
+ act(() => {
90
+ const res = result.current.startOperation({
91
+ type: 'sendMessage',
92
+ context: { sessionId, topicId },
93
+ });
94
+ operationId = res.operationId;
95
+
96
+ result.current.updateOperationMetadata(res.operationId, {
97
+ inputEditorTempState: mockEditorState,
98
+ });
99
+ });
100
+
101
+ // Cancel
102
+ act(() => {
103
+ result.current.cancelSendMessageInServer();
104
+ });
105
+
106
+ // Verify editor state was restored
107
+ expect(mockEditor.setJSONState).toHaveBeenCalledWith(mockEditorState);
108
+ });
109
+
31
110
  it('should be able to call with specified topic ID', () => {
32
111
  const { result } = renderHook(() => useChatStore());
33
112
 
34
113
  act(() => {
35
114
  useChatStore.setState({
36
115
  activeId: 'session-1',
37
- mainSendMessageOperations: {},
116
+ operations: {},
117
+ operationsByContext: {},
38
118
  });
39
119
  });
40
120
 
@@ -54,7 +134,8 @@ describe('Cancel send message functionality tests', () => {
54
134
  useChatStore.setState({
55
135
  activeId: 'session-1',
56
136
  activeTopicId: 'topic-1',
57
- mainSendMessageOperations: {},
137
+ operations: {},
138
+ operationsByContext: {},
58
139
  });
59
140
  });
60
141
 
@@ -66,42 +147,71 @@ describe('Cancel send message functionality tests', () => {
66
147
  });
67
148
  }).not.toThrow();
68
149
  });
69
- });
70
150
 
71
- describe('Internal methods', () => {
72
- it('should have internal state management methods', () => {
151
+ it('should clear error messages from sendMessage operations', () => {
73
152
  const { result } = renderHook(() => useChatStore());
74
153
 
75
- expect(typeof result.current.internal_toggleSendMessageOperation).toBe('function');
76
- expect(typeof result.current.internal_updateSendMessageOperation).toBe('function');
77
- });
78
-
79
- it('internal_toggleSendMessageOperation should work normally', () => {
80
- const { result } = renderHook(() => useChatStore());
154
+ const sessionId = 'session-1';
155
+ const topicId = 'topic-1';
81
156
 
82
157
  act(() => {
83
- useChatStore.setState({ mainSendMessageOperations: {} });
158
+ useChatStore.setState({
159
+ activeId: sessionId,
160
+ activeTopicId: topicId,
161
+ });
84
162
  });
85
163
 
86
- expect(() => {
87
- act(() => {
88
- const abortController = result.current.internal_toggleSendMessageOperation(
89
- 'test-key',
90
- true,
91
- );
92
- expect(abortController).toBeInstanceOf(AbortController);
164
+ // Create operation with error
165
+ let operationId: string;
166
+ act(() => {
167
+ const res = result.current.startOperation({
168
+ type: 'sendMessage',
169
+ context: { sessionId, topicId },
93
170
  });
94
- }).not.toThrow();
171
+ operationId = res.operationId;
172
+
173
+ result.current.updateOperationMetadata(res.operationId, {
174
+ inputSendErrorMsg: 'Test error',
175
+ });
176
+ });
177
+
178
+ expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBe('Test error');
179
+
180
+ // Clear error
181
+ act(() => {
182
+ result.current.clearSendMessageError();
183
+ });
184
+
185
+ expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBeUndefined();
95
186
  });
96
187
  });
97
188
 
98
- describe('State structure', () => {
99
- it('should have mainSendMessageOperations state', () => {
189
+ describe('Operation system', () => {
190
+ it('should have operation management methods', () => {
191
+ const { result } = renderHook(() => useChatStore());
192
+
193
+ expect(typeof result.current.startOperation).toBe('function');
194
+ expect(typeof result.current.cancelOperation).toBe('function');
195
+ expect(typeof result.current.updateOperationMetadata).toBe('function');
196
+ });
197
+
198
+ it('should track operations by context', () => {
100
199
  const { result } = renderHook(() => useChatStore());
101
200
 
102
- // Ensure state exists
103
- expect(result.current.mainSendMessageOperations).toBeDefined();
104
- expect(typeof result.current.mainSendMessageOperations).toBe('object');
201
+ const sessionId = 'session-1';
202
+ const topicId = 'topic-1';
203
+
204
+ let operationId: string;
205
+ act(() => {
206
+ const res = result.current.startOperation({
207
+ type: 'sendMessage',
208
+ context: { sessionId, topicId },
209
+ });
210
+ operationId = res.operationId;
211
+ });
212
+
213
+ const contextKey = messageMapKey(sessionId, topicId);
214
+ expect(result.current.operationsByContext[contextKey]).toContain(operationId!);
105
215
  });
106
216
  });
107
217
  });
@@ -19,103 +19,156 @@ afterEach(() => {
19
19
 
20
20
  describe('ConversationControl actions', () => {
21
21
  describe('stopGenerateMessage', () => {
22
- it('should abort generation and clear loading state when controller exists', () => {
23
- const abortController = new AbortController();
22
+ it('should cancel running generateAI operations in current context', () => {
23
+ const { result } = renderHook(() => useChatStore());
24
24
 
25
25
  act(() => {
26
- useChatStore.setState({ chatLoadingIdsAbortController: abortController });
26
+ useChatStore.setState({
27
+ activeId: TEST_IDS.SESSION_ID,
28
+ activeTopicId: TEST_IDS.TOPIC_ID,
29
+ });
27
30
  });
28
31
 
29
- const { result } = renderHook(() => useChatStore());
30
- const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading');
32
+ // Create a generateAI operation
33
+ let operationId: string;
34
+ act(() => {
35
+ const res = result.current.startOperation({
36
+ type: 'execAgentRuntime',
37
+ context: {
38
+ sessionId: TEST_IDS.SESSION_ID,
39
+ topicId: TEST_IDS.TOPIC_ID,
40
+ },
41
+ });
42
+ operationId = res.operationId;
43
+ });
44
+
45
+ expect(result.current.operations[operationId!].status).toBe('running');
31
46
 
47
+ // Stop generation
32
48
  act(() => {
33
49
  result.current.stopGenerateMessage();
34
50
  });
35
51
 
36
- expect(abortController.signal.aborted).toBe(true);
37
- expect(toggleLoadingSpy).toHaveBeenCalledWith(false, undefined, expect.any(String));
52
+ expect(result.current.operations[operationId!].status).toBe('cancelled');
38
53
  });
39
54
 
40
- it('should do nothing when abort controller is not set', () => {
55
+ it('should not cancel operations from different context', () => {
56
+ const { result } = renderHook(() => useChatStore());
57
+
41
58
  act(() => {
42
- useChatStore.setState({ chatLoadingIdsAbortController: undefined });
59
+ useChatStore.setState({
60
+ activeId: TEST_IDS.SESSION_ID,
61
+ activeTopicId: TEST_IDS.TOPIC_ID,
62
+ });
43
63
  });
44
64
 
45
- const { result } = renderHook(() => useChatStore());
46
- const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading');
65
+ // Create a generateAI operation in a different context
66
+ let operationId: string;
67
+ act(() => {
68
+ const res = result.current.startOperation({
69
+ type: 'execAgentRuntime',
70
+ context: {
71
+ sessionId: 'different-session',
72
+ topicId: 'different-topic',
73
+ },
74
+ });
75
+ operationId = res.operationId;
76
+ });
77
+
78
+ expect(result.current.operations[operationId!].status).toBe('running');
47
79
 
80
+ // Stop generation - should not affect different context
48
81
  act(() => {
49
82
  result.current.stopGenerateMessage();
50
83
  });
51
84
 
52
- expect(toggleLoadingSpy).not.toHaveBeenCalled();
85
+ expect(result.current.operations[operationId!].status).toBe('running');
53
86
  });
54
87
  });
55
88
 
56
89
  describe('cancelSendMessageInServer', () => {
57
- it('should abort operation and restore editor state when cancelling', () => {
90
+ it('should cancel operation and restore editor state', () => {
58
91
  const { result } = renderHook(() => useChatStore());
59
- const mockAbort = vi.fn();
60
92
  const mockSetJSONState = vi.fn();
93
+ const editorState = { content: 'saved content' };
61
94
 
62
95
  act(() => {
63
96
  useChatStore.setState({
64
97
  activeId: TEST_IDS.SESSION_ID,
65
98
  activeTopicId: TEST_IDS.TOPIC_ID,
66
- mainSendMessageOperations: {
67
- [messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)]: {
68
- isLoading: true,
69
- abortController: { abort: mockAbort, signal: {} as any },
70
- inputEditorTempState: { content: 'saved content' },
71
- },
72
- },
73
99
  mainInputEditor: { setJSONState: mockSetJSONState } as any,
74
100
  });
75
101
  });
76
102
 
103
+ // Create operation
104
+ let operationId: string;
105
+ act(() => {
106
+ const res = result.current.startOperation({
107
+ type: 'sendMessage',
108
+ context: {
109
+ sessionId: TEST_IDS.SESSION_ID,
110
+ topicId: TEST_IDS.TOPIC_ID,
111
+ },
112
+ });
113
+ operationId = res.operationId;
114
+
115
+ result.current.updateOperationMetadata(res.operationId, {
116
+ inputEditorTempState: editorState,
117
+ });
118
+ });
119
+
120
+ expect(result.current.operations[operationId!].status).toBe('running');
121
+
122
+ // Cancel
77
123
  act(() => {
78
124
  result.current.cancelSendMessageInServer();
79
125
  });
80
126
 
81
- expect(mockAbort).toHaveBeenCalledWith('User cancelled sendMessage operation');
82
- expect(
83
- result.current.mainSendMessageOperations[
84
- messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)
85
- ]?.isLoading,
86
- ).toBe(false);
87
- expect(mockSetJSONState).toHaveBeenCalledWith({ content: 'saved content' });
127
+ expect(result.current.operations[operationId!].status).toBe('cancelled');
128
+ expect(mockSetJSONState).toHaveBeenCalledWith(editorState);
88
129
  });
89
130
 
90
131
  it('should cancel operation for specified topic ID', () => {
91
132
  const { result } = renderHook(() => useChatStore());
92
- const mockAbort = vi.fn();
93
133
  const customTopicId = 'custom-topic-id';
94
134
 
95
135
  act(() => {
96
136
  useChatStore.setState({
97
137
  activeId: TEST_IDS.SESSION_ID,
98
- mainSendMessageOperations: {
99
- [messageMapKey(TEST_IDS.SESSION_ID, customTopicId)]: {
100
- isLoading: true,
101
- abortController: { abort: mockAbort, signal: {} as any },
102
- },
138
+ });
139
+ });
140
+
141
+ // Create operation
142
+ let operationId: string;
143
+ act(() => {
144
+ const res = result.current.startOperation({
145
+ type: 'sendMessage',
146
+ context: {
147
+ sessionId: TEST_IDS.SESSION_ID,
148
+ topicId: customTopicId,
103
149
  },
104
150
  });
151
+ operationId = res.operationId;
105
152
  });
106
153
 
154
+ expect(result.current.operations[operationId!].status).toBe('running');
155
+
156
+ // Cancel
107
157
  act(() => {
108
158
  result.current.cancelSendMessageInServer(customTopicId);
109
159
  });
110
160
 
111
- expect(mockAbort).toHaveBeenCalledWith('User cancelled sendMessage operation');
161
+ expect(result.current.operations[operationId!].status).toBe('cancelled');
112
162
  });
113
163
 
114
164
  it('should handle gracefully when operation does not exist', () => {
115
165
  const { result } = renderHook(() => useChatStore());
116
166
 
117
167
  act(() => {
118
- useChatStore.setState({ mainSendMessageOperations: {} });
168
+ useChatStore.setState({
169
+ operations: {},
170
+ operationsByContext: {},
171
+ });
119
172
  });
120
173
 
121
174
  expect(() => {
@@ -134,31 +187,44 @@ describe('ConversationControl actions', () => {
134
187
  useChatStore.setState({
135
188
  activeId: TEST_IDS.SESSION_ID,
136
189
  activeTopicId: TEST_IDS.TOPIC_ID,
137
- mainSendMessageOperations: {
138
- [messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)]: {
139
- isLoading: false,
140
- inputSendErrorMsg: 'Some error',
141
- },
190
+ });
191
+ });
192
+
193
+ // Create operation with error
194
+ let operationId: string;
195
+ act(() => {
196
+ const res = result.current.startOperation({
197
+ type: 'sendMessage',
198
+ context: {
199
+ sessionId: TEST_IDS.SESSION_ID,
200
+ topicId: TEST_IDS.TOPIC_ID,
142
201
  },
143
202
  });
203
+ operationId = res.operationId;
204
+
205
+ result.current.updateOperationMetadata(res.operationId, {
206
+ inputSendErrorMsg: 'Some error',
207
+ });
144
208
  });
145
209
 
210
+ expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBe('Some error');
211
+
212
+ // Clear error
146
213
  act(() => {
147
214
  result.current.clearSendMessageError();
148
215
  });
149
216
 
150
- expect(
151
- result.current.mainSendMessageOperations[
152
- messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)
153
- ],
154
- ).toBeUndefined();
217
+ expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBeUndefined();
155
218
  });
156
219
 
157
220
  it('should handle gracefully when no error operation exists', () => {
158
221
  const { result } = renderHook(() => useChatStore());
159
222
 
160
223
  act(() => {
161
- useChatStore.setState({ mainSendMessageOperations: {} });
224
+ useChatStore.setState({
225
+ operations: {},
226
+ operationsByContext: {},
227
+ });
162
228
  });
163
229
 
164
230
  expect(() => {
@@ -169,112 +235,80 @@ describe('ConversationControl actions', () => {
169
235
  });
170
236
  });
171
237
 
172
- describe('internal_toggleSendMessageOperation', () => {
173
- it('should create new send operation with abort controller', () => {
238
+ describe('Operation system integration', () => {
239
+ it('should create operation with abort controller', () => {
174
240
  const { result } = renderHook(() => useChatStore());
241
+
242
+ let operationId: string = '';
175
243
  let abortController: AbortController | undefined;
176
244
 
177
245
  act(() => {
178
- abortController = result.current.internal_toggleSendMessageOperation('test-key', true);
246
+ const res = result.current.startOperation({
247
+ type: 'sendMessage',
248
+ context: { sessionId: 'test-session' },
249
+ });
250
+ operationId = res.operationId;
251
+ abortController = res.abortController;
179
252
  });
180
253
 
181
254
  expect(abortController!).toBeInstanceOf(AbortController);
182
- expect(result.current.mainSendMessageOperations['test-key']?.isLoading).toBe(true);
183
- expect(result.current.mainSendMessageOperations['test-key']?.abortController).toBe(
184
- abortController,
185
- );
255
+ expect(result.current.operations[operationId!].abortController).toBe(abortController);
256
+ expect(result.current.operations[operationId!].status).toBe('running');
186
257
  });
187
258
 
188
- it('should stop send operation and clear abort controller', () => {
259
+ it('should update operation metadata', () => {
189
260
  const { result } = renderHook(() => useChatStore());
190
- const mockAbortController = { abort: vi.fn() } as any;
191
261
 
192
- let abortController: AbortController | undefined;
193
- act(() => {
194
- result.current.internal_updateSendMessageOperation('test-key', {
195
- isLoading: true,
196
- abortController: mockAbortController,
197
- });
198
-
199
- abortController = result.current.internal_toggleSendMessageOperation('test-key', false);
200
- });
201
-
202
- expect(abortController).toBeUndefined();
203
- expect(result.current.mainSendMessageOperations['test-key']?.isLoading).toBe(false);
204
- expect(result.current.mainSendMessageOperations['test-key']?.abortController).toBeNull();
205
- });
206
-
207
- it('should call abort with cancel reason when stopping', () => {
208
- const { result } = renderHook(() => useChatStore());
209
- const mockAbortController = { abort: vi.fn() } as any;
262
+ let operationId: string;
210
263
 
211
264
  act(() => {
212
- result.current.internal_updateSendMessageOperation('test-key', {
213
- isLoading: true,
214
- abortController: mockAbortController,
265
+ const res = result.current.startOperation({
266
+ type: 'sendMessage',
267
+ context: { sessionId: 'test-session' },
215
268
  });
269
+ operationId = res.operationId;
216
270
 
217
- result.current.internal_toggleSendMessageOperation('test-key', false, 'Test cancel reason');
271
+ result.current.updateOperationMetadata(res.operationId, {
272
+ inputSendErrorMsg: 'test error',
273
+ inputEditorTempState: { content: 'test' },
274
+ });
218
275
  });
219
276
 
220
- expect(mockAbortController.abort).toHaveBeenCalledWith('Test cancel reason');
277
+ expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBe('test error');
278
+ expect(result.current.operations[operationId!].metadata.inputEditorTempState).toEqual({
279
+ content: 'test',
280
+ });
221
281
  });
222
282
 
223
283
  it('should support multiple parallel operations', () => {
224
284
  const { result } = renderHook(() => useChatStore());
225
285
 
226
- let abortController1, abortController2;
227
- act(() => {
228
- abortController1 = result.current.internal_toggleSendMessageOperation('key1', true);
229
- abortController2 = result.current.internal_toggleSendMessageOperation('key2', true);
230
- });
231
-
232
- expect(result.current.mainSendMessageOperations['key1']?.isLoading).toBe(true);
233
- expect(result.current.mainSendMessageOperations['key2']?.isLoading).toBe(true);
234
- expect(abortController1).not.toBe(abortController2);
235
- });
236
- });
237
-
238
- describe('internal_updateSendMessageOperation', () => {
239
- it('should update operation state', () => {
240
- const { result } = renderHook(() => useChatStore());
241
- const mockAbortController = new AbortController();
286
+ let opId1: string = '';
287
+ let opId2: string = '';
242
288
 
243
289
  act(() => {
244
- result.current.internal_updateSendMessageOperation('test-key', {
245
- isLoading: true,
246
- abortController: mockAbortController,
247
- inputSendErrorMsg: 'test error',
290
+ const res1 = result.current.startOperation({
291
+ type: 'sendMessage',
292
+ context: { sessionId: 'session-1', topicId: 'topic-1' },
293
+ });
294
+ const res2 = result.current.startOperation({
295
+ type: 'sendMessage',
296
+ context: { sessionId: 'session-1', topicId: 'topic-2' },
248
297
  });
249
- });
250
298
 
251
- expect(result.current.mainSendMessageOperations['test-key']).toEqual({
252
- isLoading: true,
253
- abortController: mockAbortController,
254
- inputSendErrorMsg: 'test error',
299
+ opId1 = res1.operationId;
300
+ opId2 = res2.operationId;
255
301
  });
256
- });
257
-
258
- it('should support partial update of operation state', () => {
259
- const { result } = renderHook(() => useChatStore());
260
- const initialController = new AbortController();
261
302
 
262
- act(() => {
263
- result.current.internal_updateSendMessageOperation('test-key', {
264
- isLoading: true,
265
- abortController: initialController,
266
- });
303
+ expect(result.current.operations[opId1!].status).toBe('running');
304
+ expect(result.current.operations[opId2!].status).toBe('running');
305
+ expect(opId1).not.toBe(opId2);
267
306
 
268
- result.current.internal_updateSendMessageOperation('test-key', {
269
- inputSendErrorMsg: 'new error',
270
- });
271
- });
307
+ const contextKey1 = messageMapKey('session-1', 'topic-1');
308
+ const contextKey2 = messageMapKey('session-1', 'topic-2');
272
309
 
273
- expect(result.current.mainSendMessageOperations['test-key']).toEqual({
274
- isLoading: true,
275
- abortController: initialController,
276
- inputSendErrorMsg: 'new error',
277
- });
310
+ expect(result.current.operationsByContext[contextKey1]).toContain(opId1!);
311
+ expect(result.current.operationsByContext[contextKey2]).toContain(opId2!);
278
312
  });
279
313
  });
280
314
 
@@ -292,7 +326,9 @@ describe('ConversationControl actions', () => {
292
326
  await result.current.switchMessageBranch(messageId, branchIndex);
293
327
  });
294
328
 
295
- expect(optimisticUpdateSpy).toHaveBeenCalledWith(messageId, { activeBranchIndex: branchIndex });
329
+ expect(optimisticUpdateSpy).toHaveBeenCalledWith(messageId, {
330
+ activeBranchIndex: branchIndex,
331
+ });
296
332
  });
297
333
 
298
334
  it('should handle switching to branch 0', async () => {
@@ -326,7 +362,9 @@ describe('ConversationControl actions', () => {
326
362
  }),
327
363
  ).rejects.toThrow('Update failed');
328
364
 
329
- expect(optimisticUpdateSpy).toHaveBeenCalledWith(messageId, { activeBranchIndex: branchIndex });
365
+ expect(optimisticUpdateSpy).toHaveBeenCalledWith(messageId, {
366
+ activeBranchIndex: branchIndex,
367
+ });
330
368
  });
331
369
  });
332
370
  });
@@ -151,9 +151,14 @@ describe('ConversationLifecycle actions', () => {
151
151
  it('should not regenerate when already regenerating', async () => {
152
152
  const { result } = renderHook(() => useChatStore());
153
153
 
154
+ // Create a regenerate operation to simulate already regenerating
154
155
  act(() => {
156
+ const { operationId } = result.current.startOperation({
157
+ type: 'regenerate',
158
+ context: { sessionId: TEST_IDS.SESSION_ID, messageId: TEST_IDS.USER_MESSAGE_ID },
159
+ });
160
+
155
161
  useChatStore.setState({
156
- regeneratingIds: [TEST_IDS.USER_MESSAGE_ID],
157
162
  internal_execAgentRuntime: vi.fn(),
158
163
  });
159
164
  });
@@ -204,9 +209,14 @@ describe('ConversationLifecycle actions', () => {
204
209
  it('should not regenerate when already regenerating', async () => {
205
210
  const { result } = renderHook(() => useChatStore());
206
211
 
212
+ // Create a regenerate operation to simulate already regenerating
207
213
  act(() => {
214
+ result.current.startOperation({
215
+ type: 'regenerate',
216
+ context: { sessionId: TEST_IDS.SESSION_ID, messageId: TEST_IDS.MESSAGE_ID },
217
+ });
218
+
208
219
  useChatStore.setState({
209
- regeneratingIds: [TEST_IDS.MESSAGE_ID],
210
220
  internal_execAgentRuntime: vi.fn(),
211
221
  });
212
222
  });
@@ -59,8 +59,6 @@ export const createMockChatConfig = (overrides = {}) => ({
59
59
  export const createMockStoreState = (overrides = {}) => ({
60
60
  activeId: TEST_IDS.SESSION_ID,
61
61
  activeTopicId: TEST_IDS.TOPIC_ID,
62
- chatLoadingIds: [],
63
- chatLoadingIdsAbortController: undefined,
64
62
  messagesMap: {},
65
63
  toolCallingStreamIds: {},
66
64
  ...overrides,