@lobehub/lobehub 2.0.0-next.35 → 2.0.0-next.36

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 (154) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/changelog/v1.json +9 -0
  3. package/next.config.ts +5 -6
  4. package/package.json +2 -2
  5. package/packages/agent-runtime/src/core/__tests__/runtime.test.ts +112 -77
  6. package/packages/agent-runtime/src/core/runtime.ts +63 -18
  7. package/packages/agent-runtime/src/types/generalAgent.ts +55 -0
  8. package/packages/agent-runtime/src/types/index.ts +1 -0
  9. package/packages/agent-runtime/src/types/instruction.ts +10 -3
  10. package/packages/const/src/user.ts +0 -1
  11. package/packages/context-engine/src/processors/GroupMessageFlatten.ts +8 -6
  12. package/packages/context-engine/src/processors/__tests__/GroupMessageFlatten.test.ts +12 -12
  13. package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/assistant-group-branches.json +249 -0
  14. package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/index.ts +4 -0
  15. package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/multi-assistant-group.json +260 -0
  16. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/active-index-1.json +4 -0
  17. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/assistant-group-branches.json +481 -0
  18. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/conversation.json +5 -1
  19. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/index.ts +4 -0
  20. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/multi-assistant-group.json +407 -0
  21. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/nested.json +18 -2
  22. package/packages/conversation-flow/src/__tests__/fixtures/outputs/complex-scenario.json +25 -3
  23. package/packages/conversation-flow/src/__tests__/parse.test.ts +12 -0
  24. package/packages/conversation-flow/src/index.ts +1 -1
  25. package/packages/conversation-flow/src/transformation/FlatListBuilder.ts +112 -34
  26. package/packages/conversation-flow/src/types/flatMessageList.ts +0 -12
  27. package/packages/conversation-flow/src/{types.ts → types/index.ts} +3 -14
  28. package/packages/database/src/models/message.ts +18 -19
  29. package/packages/types/src/aiChat.ts +2 -0
  30. package/packages/types/src/importer.ts +2 -2
  31. package/packages/types/src/message/ui/chat.ts +17 -1
  32. package/packages/types/src/message/ui/extra.ts +2 -2
  33. package/packages/types/src/message/ui/params.ts +2 -2
  34. package/packages/types/src/user/preference.ts +0 -4
  35. package/packages/utils/src/tokenizer/index.ts +3 -11
  36. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/Desktop/MessageFromUrl.tsx +3 -3
  37. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/index.tsx +1 -1
  38. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/useSend.ts +3 -3
  39. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/useSend.ts +6 -6
  40. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/Content.tsx +5 -3
  41. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/WelcomeChatItem/AgentWelcome/OpeningQuestions.tsx +2 -2
  42. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/WelcomeChatItem/GroupWelcome/GroupUsageSuggest.tsx +2 -2
  43. package/src/app/[variants]/(main)/labs/page.tsx +0 -9
  44. package/src/features/ChatInput/ActionBar/STT/browser.tsx +3 -3
  45. package/src/features/ChatInput/ActionBar/STT/openai.tsx +3 -3
  46. package/src/features/Conversation/Error/AccessCodeForm.tsx +1 -1
  47. package/src/features/Conversation/Error/ChatInvalidApiKey.tsx +1 -1
  48. package/src/features/Conversation/Error/ClerkLogin/index.tsx +1 -1
  49. package/src/features/Conversation/Error/OAuthForm.tsx +1 -1
  50. package/src/features/Conversation/Error/index.tsx +0 -5
  51. package/src/features/Conversation/Messages/Assistant/Actions/index.tsx +13 -10
  52. package/src/features/Conversation/Messages/Assistant/Extra/index.test.tsx +3 -8
  53. package/src/features/Conversation/Messages/Assistant/Extra/index.tsx +2 -6
  54. package/src/features/Conversation/Messages/Assistant/MessageContent.tsx +7 -9
  55. package/src/features/Conversation/Messages/Assistant/Tool/Inspector/PluginResult.tsx +2 -2
  56. package/src/features/Conversation/Messages/Assistant/Tool/Inspector/PluginState.tsx +2 -2
  57. package/src/features/Conversation/Messages/Assistant/Tool/Render/PluginSettings.tsx +4 -1
  58. package/src/features/Conversation/Messages/Assistant/Tool/Render/index.tsx +2 -3
  59. package/src/features/Conversation/Messages/Assistant/index.tsx +57 -60
  60. package/src/features/Conversation/Messages/Default.tsx +1 -0
  61. package/src/features/Conversation/Messages/Group/Actions/WithContentId.tsx +38 -10
  62. package/src/features/Conversation/Messages/Group/Actions/index.tsx +1 -1
  63. package/src/features/Conversation/Messages/Group/ContentBlock.tsx +1 -3
  64. package/src/features/Conversation/Messages/Group/GroupChildren.tsx +12 -12
  65. package/src/features/Conversation/Messages/Group/MessageContent.tsx +7 -1
  66. package/src/features/Conversation/Messages/Group/Tool/Render/PluginSettings.tsx +1 -1
  67. package/src/features/Conversation/Messages/Group/index.tsx +2 -1
  68. package/src/features/Conversation/Messages/Supervisor/index.tsx +2 -2
  69. package/src/features/Conversation/Messages/User/{Actions.tsx → Actions/ActionsBar.tsx} +26 -25
  70. package/src/features/Conversation/Messages/User/Actions/MessageBranch.tsx +107 -0
  71. package/src/features/Conversation/Messages/User/Actions/index.tsx +42 -0
  72. package/src/features/Conversation/Messages/User/index.tsx +43 -44
  73. package/src/features/Conversation/Messages/index.tsx +3 -3
  74. package/src/features/Conversation/components/AutoScroll.tsx +3 -3
  75. package/src/features/Conversation/components/Extras/Usage/UsageDetail/AnimatedNumber.tsx +55 -0
  76. package/src/features/Conversation/components/Extras/Usage/UsageDetail/index.tsx +5 -2
  77. package/src/features/Conversation/components/VirtualizedList/index.tsx +29 -20
  78. package/src/features/Conversation/hooks/useChatListActionsBar.tsx +8 -10
  79. package/src/features/Portal/Thread/Chat/ChatInput/useSend.ts +3 -3
  80. package/src/hooks/useHotkeys/chatScope.ts +15 -7
  81. package/src/server/routers/lambda/__tests__/aiChat.test.ts +1 -1
  82. package/src/server/routers/lambda/__tests__/integration/message.integration.test.ts +0 -26
  83. package/src/server/routers/lambda/aiChat.ts +3 -2
  84. package/src/server/routers/lambda/message.ts +8 -16
  85. package/src/server/services/message/__tests__/index.test.ts +29 -39
  86. package/src/server/services/message/index.ts +41 -36
  87. package/src/services/electron/desktopNotification.ts +6 -6
  88. package/src/services/electron/file.ts +6 -6
  89. package/src/services/file/ClientS3/index.ts +8 -8
  90. package/src/services/message/__tests__/metadata-race-condition.test.ts +157 -0
  91. package/src/services/message/index.ts +21 -15
  92. package/src/services/upload.ts +11 -11
  93. package/src/services/utils/abortableRequest.test.ts +161 -0
  94. package/src/services/utils/abortableRequest.ts +67 -0
  95. package/src/store/chat/agents/GeneralChatAgent.ts +137 -0
  96. package/src/store/chat/agents/createAgentExecutors.ts +395 -0
  97. package/src/store/chat/helpers.test.ts +0 -99
  98. package/src/store/chat/helpers.ts +0 -11
  99. package/src/store/chat/slices/aiChat/actions/__tests__/conversationControl.test.ts +332 -0
  100. package/src/store/chat/slices/aiChat/actions/__tests__/conversationLifecycle.test.ts +257 -0
  101. package/src/store/chat/slices/aiChat/actions/__tests__/helpers.ts +11 -2
  102. package/src/store/chat/slices/aiChat/actions/__tests__/rag.test.ts +6 -6
  103. package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +391 -0
  104. package/src/store/chat/slices/aiChat/actions/__tests__/streamingStates.test.ts +179 -0
  105. package/src/store/chat/slices/aiChat/actions/conversationControl.ts +157 -0
  106. package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +329 -0
  107. package/src/store/chat/slices/aiChat/actions/generateAIGroupChat.ts +14 -14
  108. package/src/store/chat/slices/aiChat/actions/index.ts +12 -6
  109. package/src/store/chat/slices/aiChat/actions/rag.ts +9 -6
  110. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +604 -0
  111. package/src/store/chat/slices/aiChat/actions/streamingStates.ts +84 -0
  112. package/src/store/chat/slices/builtinTool/actions/__tests__/localSystem.test.ts +4 -4
  113. package/src/store/chat/slices/builtinTool/actions/__tests__/search.test.ts +11 -11
  114. package/src/store/chat/slices/builtinTool/actions/interpreter.ts +8 -8
  115. package/src/store/chat/slices/builtinTool/actions/localSystem.ts +2 -2
  116. package/src/store/chat/slices/builtinTool/actions/search.ts +8 -8
  117. package/src/store/chat/slices/message/action.test.ts +79 -68
  118. package/src/store/chat/slices/message/actions/index.ts +39 -0
  119. package/src/store/chat/slices/message/actions/internals.ts +77 -0
  120. package/src/store/chat/slices/message/actions/optimisticUpdate.ts +260 -0
  121. package/src/store/chat/slices/message/actions/publicApi.ts +224 -0
  122. package/src/store/chat/slices/message/actions/query.ts +120 -0
  123. package/src/store/chat/slices/message/actions/runtimeState.ts +108 -0
  124. package/src/store/chat/slices/message/initialState.ts +13 -0
  125. package/src/store/chat/slices/message/reducer.test.ts +48 -370
  126. package/src/store/chat/slices/message/reducer.ts +17 -81
  127. package/src/store/chat/slices/message/selectors/chat.test.ts +13 -50
  128. package/src/store/chat/slices/message/selectors/chat.ts +78 -242
  129. package/src/store/chat/slices/message/selectors/dbMessage.ts +140 -0
  130. package/src/store/chat/slices/message/selectors/displayMessage.ts +301 -0
  131. package/src/store/chat/slices/message/selectors/messageState.ts +5 -2
  132. package/src/store/chat/slices/plugin/action.test.ts +62 -64
  133. package/src/store/chat/slices/plugin/action.ts +34 -28
  134. package/src/store/chat/slices/thread/action.test.ts +28 -31
  135. package/src/store/chat/slices/thread/action.ts +13 -10
  136. package/src/store/chat/slices/thread/selectors/index.ts +8 -6
  137. package/src/store/chat/slices/topic/reducer.ts +11 -3
  138. package/src/store/chat/store.ts +1 -1
  139. package/src/store/user/slices/preference/selectors/labPrefer.ts +0 -3
  140. package/packages/database/src/models/__tests__/message.grouping.test.ts +0 -812
  141. package/packages/database/src/utils/__tests__/groupMessages.test.ts +0 -1132
  142. package/packages/database/src/utils/groupMessages.ts +0 -361
  143. package/packages/utils/src/tokenizer/client.ts +0 -35
  144. package/packages/utils/src/tokenizer/estimated.ts +0 -4
  145. package/packages/utils/src/tokenizer/server.ts +0 -11
  146. package/packages/utils/src/tokenizer/tokenizer.worker.ts +0 -12
  147. package/src/app/(backend)/webapi/tokenizer/index.test.ts +0 -32
  148. package/src/app/(backend)/webapi/tokenizer/route.ts +0 -8
  149. package/src/features/Conversation/Error/InvalidAccessCode.tsx +0 -79
  150. package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChat.test.ts +0 -975
  151. package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts +0 -1050
  152. package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +0 -720
  153. package/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts +0 -849
  154. package/src/store/chat/slices/message/action.ts +0 -629
@@ -0,0 +1,332 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { useChatStore } from '../../../../store';
5
+ import { messageMapKey } from '../../../../utils/messageMapKey';
6
+ import { TEST_IDS } from './fixtures';
7
+ import { resetTestEnvironment } from './helpers';
8
+
9
+ // Keep zustand mock as it's needed globally
10
+ vi.mock('zustand/traditional');
11
+
12
+ beforeEach(() => {
13
+ resetTestEnvironment();
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ describe('ConversationControl actions', () => {
21
+ describe('stopGenerateMessage', () => {
22
+ it('should abort generation and clear loading state when controller exists', () => {
23
+ const abortController = new AbortController();
24
+
25
+ act(() => {
26
+ useChatStore.setState({ chatLoadingIdsAbortController: abortController });
27
+ });
28
+
29
+ const { result } = renderHook(() => useChatStore());
30
+ const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading');
31
+
32
+ act(() => {
33
+ result.current.stopGenerateMessage();
34
+ });
35
+
36
+ expect(abortController.signal.aborted).toBe(true);
37
+ expect(toggleLoadingSpy).toHaveBeenCalledWith(false, undefined, expect.any(String));
38
+ });
39
+
40
+ it('should do nothing when abort controller is not set', () => {
41
+ act(() => {
42
+ useChatStore.setState({ chatLoadingIdsAbortController: undefined });
43
+ });
44
+
45
+ const { result } = renderHook(() => useChatStore());
46
+ const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading');
47
+
48
+ act(() => {
49
+ result.current.stopGenerateMessage();
50
+ });
51
+
52
+ expect(toggleLoadingSpy).not.toHaveBeenCalled();
53
+ });
54
+ });
55
+
56
+ describe('cancelSendMessageInServer', () => {
57
+ it('should abort operation and restore editor state when cancelling', () => {
58
+ const { result } = renderHook(() => useChatStore());
59
+ const mockAbort = vi.fn();
60
+ const mockSetJSONState = vi.fn();
61
+
62
+ act(() => {
63
+ useChatStore.setState({
64
+ activeId: TEST_IDS.SESSION_ID,
65
+ 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
+ mainInputEditor: { setJSONState: mockSetJSONState } as any,
74
+ });
75
+ });
76
+
77
+ act(() => {
78
+ result.current.cancelSendMessageInServer();
79
+ });
80
+
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' });
88
+ });
89
+
90
+ it('should cancel operation for specified topic ID', () => {
91
+ const { result } = renderHook(() => useChatStore());
92
+ const mockAbort = vi.fn();
93
+ const customTopicId = 'custom-topic-id';
94
+
95
+ act(() => {
96
+ useChatStore.setState({
97
+ 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
+ },
103
+ },
104
+ });
105
+ });
106
+
107
+ act(() => {
108
+ result.current.cancelSendMessageInServer(customTopicId);
109
+ });
110
+
111
+ expect(mockAbort).toHaveBeenCalledWith('User cancelled sendMessage operation');
112
+ });
113
+
114
+ it('should handle gracefully when operation does not exist', () => {
115
+ const { result } = renderHook(() => useChatStore());
116
+
117
+ act(() => {
118
+ useChatStore.setState({ mainSendMessageOperations: {} });
119
+ });
120
+
121
+ expect(() => {
122
+ act(() => {
123
+ result.current.cancelSendMessageInServer('non-existing-topic');
124
+ });
125
+ }).not.toThrow();
126
+ });
127
+ });
128
+
129
+ describe('clearSendMessageError', () => {
130
+ it('should clear error state for current topic', () => {
131
+ const { result } = renderHook(() => useChatStore());
132
+
133
+ act(() => {
134
+ useChatStore.setState({
135
+ activeId: TEST_IDS.SESSION_ID,
136
+ 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
+ },
142
+ },
143
+ });
144
+ });
145
+
146
+ act(() => {
147
+ result.current.clearSendMessageError();
148
+ });
149
+
150
+ expect(
151
+ result.current.mainSendMessageOperations[
152
+ messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)
153
+ ],
154
+ ).toBeUndefined();
155
+ });
156
+
157
+ it('should handle gracefully when no error operation exists', () => {
158
+ const { result } = renderHook(() => useChatStore());
159
+
160
+ act(() => {
161
+ useChatStore.setState({ mainSendMessageOperations: {} });
162
+ });
163
+
164
+ expect(() => {
165
+ act(() => {
166
+ result.current.clearSendMessageError();
167
+ });
168
+ }).not.toThrow();
169
+ });
170
+ });
171
+
172
+ describe('internal_toggleSendMessageOperation', () => {
173
+ it('should create new send operation with abort controller', () => {
174
+ const { result } = renderHook(() => useChatStore());
175
+ let abortController: AbortController | undefined;
176
+
177
+ act(() => {
178
+ abortController = result.current.internal_toggleSendMessageOperation('test-key', true);
179
+ });
180
+
181
+ 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
+ );
186
+ });
187
+
188
+ it('should stop send operation and clear abort controller', () => {
189
+ const { result } = renderHook(() => useChatStore());
190
+ const mockAbortController = { abort: vi.fn() } as any;
191
+
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;
210
+
211
+ act(() => {
212
+ result.current.internal_updateSendMessageOperation('test-key', {
213
+ isLoading: true,
214
+ abortController: mockAbortController,
215
+ });
216
+
217
+ result.current.internal_toggleSendMessageOperation('test-key', false, 'Test cancel reason');
218
+ });
219
+
220
+ expect(mockAbortController.abort).toHaveBeenCalledWith('Test cancel reason');
221
+ });
222
+
223
+ it('should support multiple parallel operations', () => {
224
+ const { result } = renderHook(() => useChatStore());
225
+
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();
242
+
243
+ act(() => {
244
+ result.current.internal_updateSendMessageOperation('test-key', {
245
+ isLoading: true,
246
+ abortController: mockAbortController,
247
+ inputSendErrorMsg: 'test error',
248
+ });
249
+ });
250
+
251
+ expect(result.current.mainSendMessageOperations['test-key']).toEqual({
252
+ isLoading: true,
253
+ abortController: mockAbortController,
254
+ inputSendErrorMsg: 'test error',
255
+ });
256
+ });
257
+
258
+ it('should support partial update of operation state', () => {
259
+ const { result } = renderHook(() => useChatStore());
260
+ const initialController = new AbortController();
261
+
262
+ act(() => {
263
+ result.current.internal_updateSendMessageOperation('test-key', {
264
+ isLoading: true,
265
+ abortController: initialController,
266
+ });
267
+
268
+ result.current.internal_updateSendMessageOperation('test-key', {
269
+ inputSendErrorMsg: 'new error',
270
+ });
271
+ });
272
+
273
+ expect(result.current.mainSendMessageOperations['test-key']).toEqual({
274
+ isLoading: true,
275
+ abortController: initialController,
276
+ inputSendErrorMsg: 'new error',
277
+ });
278
+ });
279
+ });
280
+
281
+ describe('switchMessageBranch', () => {
282
+ it('should switch to a different message branch', async () => {
283
+ const { result } = renderHook(() => useChatStore());
284
+ const messageId = TEST_IDS.MESSAGE_ID;
285
+ const branchIndex = 1;
286
+
287
+ const optimisticUpdateSpy = vi
288
+ .spyOn(result.current, 'optimisticUpdateMessageMetadata')
289
+ .mockResolvedValue(undefined);
290
+
291
+ await act(async () => {
292
+ await result.current.switchMessageBranch(messageId, branchIndex);
293
+ });
294
+
295
+ expect(optimisticUpdateSpy).toHaveBeenCalledWith(messageId, { activeBranchIndex: branchIndex });
296
+ });
297
+
298
+ it('should handle switching to branch 0', async () => {
299
+ const { result } = renderHook(() => useChatStore());
300
+ const messageId = TEST_IDS.MESSAGE_ID;
301
+ const branchIndex = 0;
302
+
303
+ const optimisticUpdateSpy = vi
304
+ .spyOn(result.current, 'optimisticUpdateMessageMetadata')
305
+ .mockResolvedValue(undefined);
306
+
307
+ await act(async () => {
308
+ await result.current.switchMessageBranch(messageId, branchIndex);
309
+ });
310
+
311
+ expect(optimisticUpdateSpy).toHaveBeenCalledWith(messageId, { activeBranchIndex: 0 });
312
+ });
313
+
314
+ it('should handle errors gracefully when optimistic update fails', async () => {
315
+ const { result } = renderHook(() => useChatStore());
316
+ const messageId = TEST_IDS.MESSAGE_ID;
317
+ const branchIndex = 2;
318
+
319
+ const optimisticUpdateSpy = vi
320
+ .spyOn(result.current, 'optimisticUpdateMessageMetadata')
321
+ .mockRejectedValue(new Error('Update failed'));
322
+
323
+ await expect(
324
+ act(async () => {
325
+ await result.current.switchMessageBranch(messageId, branchIndex);
326
+ }),
327
+ ).rejects.toThrow('Update failed');
328
+
329
+ expect(optimisticUpdateSpy).toHaveBeenCalledWith(messageId, { activeBranchIndex: branchIndex });
330
+ });
331
+ });
332
+ });
@@ -0,0 +1,257 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { aiChatService } from '@/services/aiChat';
5
+
6
+ import { useChatStore } from '../../../../store';
7
+ import { TEST_CONTENT, TEST_IDS, createMockMessage } from './fixtures';
8
+ import {
9
+ resetTestEnvironment,
10
+ setupMockSelectors,
11
+ setupStoreWithMessages,
12
+ spyOnMessageService,
13
+ } from './helpers';
14
+
15
+ // Keep zustand mock as it's needed globally
16
+ vi.mock('zustand/traditional');
17
+
18
+ beforeEach(() => {
19
+ resetTestEnvironment();
20
+ setupMockSelectors();
21
+ spyOnMessageService();
22
+
23
+ act(() => {
24
+ useChatStore.setState({
25
+ refreshMessages: vi.fn(),
26
+ refreshTopic: vi.fn(),
27
+ internal_execAgentRuntime: vi.fn(),
28
+ });
29
+ });
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.restoreAllMocks();
34
+ });
35
+
36
+ describe('ConversationLifecycle actions', () => {
37
+ describe('sendMessage', () => {
38
+ describe('validation', () => {
39
+ it('should not send when there is no active session', async () => {
40
+ act(() => {
41
+ useChatStore.setState({ activeId: undefined });
42
+ });
43
+
44
+ const { result } = renderHook(() => useChatStore());
45
+
46
+ await act(async () => {
47
+ await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE });
48
+ });
49
+
50
+ expect(result.current.internal_execAgentRuntime).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('should not send when message is empty and no files are provided', async () => {
54
+ const { result } = renderHook(() => useChatStore());
55
+
56
+ await act(async () => {
57
+ await result.current.sendMessage({ message: TEST_CONTENT.EMPTY });
58
+ });
59
+
60
+ expect(result.current.internal_execAgentRuntime).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('should not send when message is empty with empty files array', async () => {
64
+ const { result } = renderHook(() => useChatStore());
65
+
66
+ await act(async () => {
67
+ await result.current.sendMessage({ message: TEST_CONTENT.EMPTY, files: [] });
68
+ });
69
+
70
+ expect(result.current.internal_execAgentRuntime).not.toHaveBeenCalled();
71
+ });
72
+ });
73
+
74
+ describe('message creation', () => {
75
+ it('should not process AI when onlyAddUserMessage is true', async () => {
76
+ const { result } = renderHook(() => useChatStore());
77
+
78
+ vi.spyOn(aiChatService, 'sendMessageInServer').mockResolvedValue({
79
+ messages: [],
80
+ topics: [],
81
+ assistantMessageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
82
+ } as any);
83
+
84
+ await act(async () => {
85
+ await result.current.sendMessage({
86
+ message: TEST_CONTENT.USER_MESSAGE,
87
+ onlyAddUserMessage: true,
88
+ });
89
+ });
90
+
91
+ expect(result.current.internal_execAgentRuntime).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it('should create user message and trigger AI processing', async () => {
95
+ const { result } = renderHook(() => useChatStore());
96
+
97
+ vi.spyOn(aiChatService, 'sendMessageInServer').mockResolvedValue({
98
+ messages: [
99
+ createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
100
+ createMockMessage({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, role: 'assistant' }),
101
+ ],
102
+ topics: [],
103
+ assistantMessageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
104
+ } as any);
105
+
106
+ await act(async () => {
107
+ await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE });
108
+ });
109
+
110
+ expect(result.current.internal_execAgentRuntime).toHaveBeenCalled();
111
+ });
112
+ });
113
+ });
114
+
115
+ describe('regenerateUserMessage', () => {
116
+ it('should trigger user message regeneration', async () => {
117
+ const messages = [
118
+ createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user', content: 'test' }),
119
+ createMockMessage({ id: TEST_IDS.MESSAGE_ID, role: 'assistant' }),
120
+ ];
121
+
122
+ setupStoreWithMessages(messages);
123
+
124
+ const switchMessageBranchSpy = vi.fn().mockResolvedValue(undefined);
125
+ const internalTraceSpy = vi.fn();
126
+
127
+ act(() => {
128
+ useChatStore.setState({
129
+ internal_traceMessage: internalTraceSpy,
130
+ switchMessageBranch: switchMessageBranchSpy,
131
+ internal_shouldUseRAG: vi.fn().mockReturnValue(false),
132
+ });
133
+ });
134
+
135
+ const { result } = renderHook(() => useChatStore());
136
+
137
+ await act(async () => {
138
+ await result.current.regenerateUserMessage(TEST_IDS.USER_MESSAGE_ID);
139
+ });
140
+
141
+ expect(switchMessageBranchSpy).toHaveBeenCalledWith(TEST_IDS.USER_MESSAGE_ID, 1);
142
+ expect(result.current.internal_execAgentRuntime).toHaveBeenCalledWith(
143
+ expect.objectContaining({
144
+ parentMessageId: TEST_IDS.USER_MESSAGE_ID,
145
+ parentMessageType: 'user',
146
+ }),
147
+ );
148
+ expect(internalTraceSpy).toHaveBeenCalled();
149
+ });
150
+
151
+ it('should not regenerate when already regenerating', async () => {
152
+ const { result } = renderHook(() => useChatStore());
153
+
154
+ act(() => {
155
+ useChatStore.setState({
156
+ regeneratingIds: [TEST_IDS.USER_MESSAGE_ID],
157
+ internal_execAgentRuntime: vi.fn(),
158
+ });
159
+ });
160
+
161
+ await act(async () => {
162
+ await result.current.regenerateUserMessage(TEST_IDS.USER_MESSAGE_ID);
163
+ });
164
+
165
+ expect(result.current.internal_execAgentRuntime).not.toHaveBeenCalled();
166
+ });
167
+ });
168
+
169
+ describe('regenerateAssistantMessage', () => {
170
+ it('should trigger assistant message regeneration', async () => {
171
+ const messages = [
172
+ createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
173
+ createMockMessage({
174
+ id: TEST_IDS.MESSAGE_ID,
175
+ role: 'assistant',
176
+ parentId: TEST_IDS.USER_MESSAGE_ID,
177
+ }),
178
+ ];
179
+
180
+ setupStoreWithMessages(messages);
181
+
182
+ act(() => {
183
+ useChatStore.setState({
184
+ internal_traceMessage: vi.fn(),
185
+ switchMessageBranch: vi.fn(),
186
+ });
187
+ });
188
+
189
+ const { result } = renderHook(() => useChatStore());
190
+
191
+ await act(async () => {
192
+ await result.current.regenerateAssistantMessage(TEST_IDS.MESSAGE_ID);
193
+ });
194
+
195
+ expect(result.current.internal_execAgentRuntime).toHaveBeenCalledWith(
196
+ expect.objectContaining({
197
+ parentMessageId: TEST_IDS.USER_MESSAGE_ID,
198
+ parentMessageType: 'user',
199
+ }),
200
+ );
201
+ expect(result.current.internal_traceMessage).toHaveBeenCalled();
202
+ });
203
+
204
+ it('should not regenerate when already regenerating', async () => {
205
+ const { result } = renderHook(() => useChatStore());
206
+
207
+ act(() => {
208
+ useChatStore.setState({
209
+ regeneratingIds: [TEST_IDS.MESSAGE_ID],
210
+ internal_execAgentRuntime: vi.fn(),
211
+ });
212
+ });
213
+
214
+ await act(async () => {
215
+ await result.current.regenerateAssistantMessage(TEST_IDS.MESSAGE_ID);
216
+ });
217
+
218
+ expect(result.current.internal_execAgentRuntime).not.toHaveBeenCalled();
219
+ });
220
+ });
221
+
222
+ describe('delAndRegenerateMessage', () => {
223
+ it('should delete message then regenerate', async () => {
224
+ const messages = [
225
+ createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
226
+ createMockMessage({
227
+ id: TEST_IDS.MESSAGE_ID,
228
+ role: 'assistant',
229
+ parentId: TEST_IDS.USER_MESSAGE_ID,
230
+ }),
231
+ ];
232
+
233
+ setupStoreWithMessages(messages);
234
+
235
+ act(() => {
236
+ useChatStore.setState({
237
+ regenerateAssistantMessage: vi.fn(),
238
+ deleteMessage: vi.fn(),
239
+ internal_traceMessage: vi.fn(),
240
+ });
241
+ });
242
+
243
+ const { result } = renderHook(() => useChatStore());
244
+
245
+ await act(async () => {
246
+ await result.current.delAndRegenerateMessage(TEST_IDS.MESSAGE_ID);
247
+ });
248
+
249
+ expect(result.current.regenerateAssistantMessage).toHaveBeenCalledWith(
250
+ TEST_IDS.MESSAGE_ID,
251
+ expect.objectContaining({ skipTrace: true }),
252
+ );
253
+ expect(result.current.deleteMessage).toHaveBeenCalledWith(TEST_IDS.MESSAGE_ID);
254
+ expect(result.current.internal_traceMessage).toHaveBeenCalled();
255
+ });
256
+ });
257
+ });
@@ -35,11 +35,16 @@ export const setupMockSelectors = (
35
35
  /**
36
36
  * Setup store state with messages
37
37
  */
38
- export const setupStoreWithMessages = (messages: any[], sessionId = TEST_IDS.SESSION_ID) => {
38
+ export const setupStoreWithMessages = (
39
+ messages: any[],
40
+ sessionId = TEST_IDS.SESSION_ID,
41
+ topicId: string | null | undefined = TEST_IDS.TOPIC_ID,
42
+ ) => {
39
43
  useChatStore.setState({
40
44
  activeId: sessionId,
45
+ activeTopicId: topicId ?? undefined,
41
46
  messagesMap: {
42
- [messageMapKey(sessionId)]: messages,
47
+ [messageMapKey(sessionId, topicId ?? undefined)]: messages,
43
48
  },
44
49
  });
45
50
  };
@@ -63,6 +68,9 @@ export const spyOnMessageService = () => {
63
68
  const updateMessageSpy = vi
64
69
  .spyOn(messageService, 'updateMessage')
65
70
  .mockResolvedValue({ messages: [], success: true });
71
+ const updateMessageMetadataSpy = vi
72
+ .spyOn(messageService, 'updateMessageMetadata')
73
+ .mockResolvedValue({ messages: [], success: true });
66
74
  const removeMessageSpy = vi
67
75
  .spyOn(messageService, 'removeMessage')
68
76
  .mockResolvedValue(undefined as any);
@@ -74,6 +82,7 @@ export const spyOnMessageService = () => {
74
82
  createMessageSpy,
75
83
  removeMessageSpy,
76
84
  updateMessageErrorSpy,
85
+ updateMessageMetadataSpy,
77
86
  updateMessageSpy,
78
87
  };
79
88
  };
@@ -6,7 +6,7 @@ import { chatService } from '@/services/chat';
6
6
  import { ragService } from '@/services/rag';
7
7
  import { useAgentStore } from '@/store/agent';
8
8
  import { agentSelectors } from '@/store/agent/selectors';
9
- import { chatSelectors } from '@/store/chat/selectors';
9
+ import { chatSelectors, dbMessageSelectors, displayMessageSelectors } from '@/store/chat/selectors';
10
10
  import { systemAgentSelectors } from '@/store/user/selectors';
11
11
  import { QueryRewriteSystemAgent } from '@/types/user/settings';
12
12
 
@@ -70,7 +70,7 @@ describe('chatRAG actions', () => {
70
70
  const userQuery = 'user-query';
71
71
 
72
72
  // Mock the message with existing ragQuery
73
- vi.spyOn(chatSelectors, 'getMessageById').mockReturnValue(
73
+ vi.spyOn(dbMessageSelectors, 'getDbMessageById').mockReturnValue(
74
74
  () =>
75
75
  ({
76
76
  id: messageId,
@@ -113,7 +113,7 @@ describe('chatRAG actions', () => {
113
113
  const rewrittenQuery = 'rewritten-query';
114
114
 
115
115
  // Mock the message without ragQuery
116
- vi.spyOn(chatSelectors, 'getMessageById').mockReturnValue(
116
+ vi.spyOn(dbMessageSelectors, 'getDbMessageById').mockReturnValue(
117
117
  () =>
118
118
  ({
119
119
  id: messageId,
@@ -220,7 +220,7 @@ describe('chatRAG actions', () => {
220
220
  it('should not rewrite if message not found', async () => {
221
221
  const { result } = renderHook(() => useChatStore());
222
222
 
223
- vi.spyOn(chatSelectors, 'getMessageById').mockReturnValue(() => undefined);
223
+ vi.spyOn(dbMessageSelectors, 'getDbMessageById').mockReturnValue(() => undefined);
224
224
  const rewriteSpy = vi.spyOn(result.current, 'internal_rewriteQuery');
225
225
 
226
226
  await act(async () => {
@@ -235,7 +235,7 @@ describe('chatRAG actions', () => {
235
235
  const messageId = 'message-id';
236
236
  const content = 'message content';
237
237
 
238
- vi.spyOn(chatSelectors, 'getMessageById').mockReturnValue(
238
+ vi.spyOn(dbMessageSelectors, 'getDbMessageById').mockReturnValue(
239
239
  () =>
240
240
  ({
241
241
  id: messageId,
@@ -243,7 +243,7 @@ describe('chatRAG actions', () => {
243
243
  }) as UIChatMessage,
244
244
  );
245
245
 
246
- vi.spyOn(chatSelectors, 'mainAIChatsWithHistoryConfig').mockReturnValue([
246
+ vi.spyOn(displayMessageSelectors, 'mainAIChatsWithHistoryConfig').mockReturnValue([
247
247
  { content: 'history' },
248
248
  ] as UIChatMessage[]);
249
249