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

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 (156) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/changelog/v1.json +18 -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/__tests__/apiKey.test.ts +444 -0
  29. package/packages/database/src/models/message.ts +18 -19
  30. package/packages/types/src/aiChat.ts +2 -0
  31. package/packages/types/src/importer.ts +2 -2
  32. package/packages/types/src/message/ui/chat.ts +17 -1
  33. package/packages/types/src/message/ui/extra.ts +2 -2
  34. package/packages/types/src/message/ui/params.ts +2 -2
  35. package/packages/types/src/user/preference.ts +0 -4
  36. package/packages/utils/src/tokenizer/index.ts +3 -11
  37. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/Desktop/MessageFromUrl.tsx +3 -3
  38. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/index.tsx +1 -1
  39. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/useSend.ts +3 -3
  40. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/useSend.ts +6 -6
  41. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/Content.tsx +5 -3
  42. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/WelcomeChatItem/AgentWelcome/OpeningQuestions.tsx +2 -2
  43. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/WelcomeChatItem/GroupWelcome/GroupUsageSuggest.tsx +2 -2
  44. package/src/app/[variants]/(main)/labs/page.tsx +0 -9
  45. package/src/features/ChatInput/ActionBar/STT/browser.tsx +3 -3
  46. package/src/features/ChatInput/ActionBar/STT/openai.tsx +3 -3
  47. package/src/features/Conversation/Error/AccessCodeForm.tsx +1 -1
  48. package/src/features/Conversation/Error/ChatInvalidApiKey.tsx +1 -1
  49. package/src/features/Conversation/Error/ClerkLogin/index.tsx +1 -1
  50. package/src/features/Conversation/Error/OAuthForm.tsx +1 -1
  51. package/src/features/Conversation/Error/index.tsx +0 -5
  52. package/src/features/Conversation/Messages/Assistant/Actions/index.tsx +13 -10
  53. package/src/features/Conversation/Messages/Assistant/Extra/index.test.tsx +3 -8
  54. package/src/features/Conversation/Messages/Assistant/Extra/index.tsx +2 -6
  55. package/src/features/Conversation/Messages/Assistant/MessageContent.tsx +7 -9
  56. package/src/features/Conversation/Messages/Assistant/Tool/Inspector/PluginResult.tsx +2 -2
  57. package/src/features/Conversation/Messages/Assistant/Tool/Inspector/PluginState.tsx +2 -2
  58. package/src/features/Conversation/Messages/Assistant/Tool/Render/PluginSettings.tsx +4 -1
  59. package/src/features/Conversation/Messages/Assistant/Tool/Render/index.tsx +2 -3
  60. package/src/features/Conversation/Messages/Assistant/index.tsx +57 -60
  61. package/src/features/Conversation/Messages/Default.tsx +1 -0
  62. package/src/features/Conversation/Messages/Group/Actions/WithContentId.tsx +38 -10
  63. package/src/features/Conversation/Messages/Group/Actions/index.tsx +1 -1
  64. package/src/features/Conversation/Messages/Group/ContentBlock.tsx +1 -3
  65. package/src/features/Conversation/Messages/Group/GroupChildren.tsx +12 -12
  66. package/src/features/Conversation/Messages/Group/MessageContent.tsx +7 -1
  67. package/src/features/Conversation/Messages/Group/Tool/Render/PluginSettings.tsx +1 -1
  68. package/src/features/Conversation/Messages/Group/index.tsx +2 -1
  69. package/src/features/Conversation/Messages/Supervisor/index.tsx +2 -2
  70. package/src/features/Conversation/Messages/User/{Actions.tsx → Actions/ActionsBar.tsx} +26 -25
  71. package/src/features/Conversation/Messages/User/Actions/MessageBranch.tsx +107 -0
  72. package/src/features/Conversation/Messages/User/Actions/index.tsx +42 -0
  73. package/src/features/Conversation/Messages/User/index.tsx +43 -44
  74. package/src/features/Conversation/Messages/index.tsx +3 -3
  75. package/src/features/Conversation/components/AutoScroll.tsx +3 -3
  76. package/src/features/Conversation/components/Extras/Usage/UsageDetail/AnimatedNumber.tsx +55 -0
  77. package/src/features/Conversation/components/Extras/Usage/UsageDetail/index.tsx +5 -2
  78. package/src/features/Conversation/components/VirtualizedList/index.tsx +29 -20
  79. package/src/features/Conversation/hooks/useChatListActionsBar.tsx +8 -10
  80. package/src/features/Portal/Thread/Chat/ChatInput/useSend.ts +3 -3
  81. package/src/hooks/useHotkeys/chatScope.ts +15 -7
  82. package/src/libs/trpc/client/lambda.ts +4 -3
  83. package/src/server/routers/lambda/__tests__/aiChat.test.ts +1 -1
  84. package/src/server/routers/lambda/__tests__/integration/message.integration.test.ts +0 -26
  85. package/src/server/routers/lambda/aiChat.ts +3 -2
  86. package/src/server/routers/lambda/message.ts +8 -16
  87. package/src/server/services/message/__tests__/index.test.ts +29 -39
  88. package/src/server/services/message/index.ts +41 -36
  89. package/src/services/electron/desktopNotification.ts +6 -6
  90. package/src/services/electron/file.ts +6 -6
  91. package/src/services/file/ClientS3/index.ts +8 -8
  92. package/src/services/message/__tests__/metadata-race-condition.test.ts +157 -0
  93. package/src/services/message/index.ts +21 -15
  94. package/src/services/upload.ts +11 -11
  95. package/src/services/utils/abortableRequest.test.ts +161 -0
  96. package/src/services/utils/abortableRequest.ts +67 -0
  97. package/src/store/chat/agents/GeneralChatAgent.ts +137 -0
  98. package/src/store/chat/agents/createAgentExecutors.ts +395 -0
  99. package/src/store/chat/helpers.test.ts +0 -99
  100. package/src/store/chat/helpers.ts +0 -11
  101. package/src/store/chat/slices/aiChat/actions/__tests__/conversationControl.test.ts +332 -0
  102. package/src/store/chat/slices/aiChat/actions/__tests__/conversationLifecycle.test.ts +257 -0
  103. package/src/store/chat/slices/aiChat/actions/__tests__/helpers.ts +11 -2
  104. package/src/store/chat/slices/aiChat/actions/__tests__/rag.test.ts +6 -6
  105. package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +391 -0
  106. package/src/store/chat/slices/aiChat/actions/__tests__/streamingStates.test.ts +179 -0
  107. package/src/store/chat/slices/aiChat/actions/conversationControl.ts +157 -0
  108. package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +329 -0
  109. package/src/store/chat/slices/aiChat/actions/generateAIGroupChat.ts +14 -14
  110. package/src/store/chat/slices/aiChat/actions/index.ts +12 -6
  111. package/src/store/chat/slices/aiChat/actions/rag.ts +9 -6
  112. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +604 -0
  113. package/src/store/chat/slices/aiChat/actions/streamingStates.ts +84 -0
  114. package/src/store/chat/slices/builtinTool/actions/__tests__/localSystem.test.ts +4 -4
  115. package/src/store/chat/slices/builtinTool/actions/__tests__/search.test.ts +11 -11
  116. package/src/store/chat/slices/builtinTool/actions/interpreter.ts +8 -8
  117. package/src/store/chat/slices/builtinTool/actions/localSystem.ts +2 -2
  118. package/src/store/chat/slices/builtinTool/actions/search.ts +8 -8
  119. package/src/store/chat/slices/message/action.test.ts +79 -68
  120. package/src/store/chat/slices/message/actions/index.ts +39 -0
  121. package/src/store/chat/slices/message/actions/internals.ts +77 -0
  122. package/src/store/chat/slices/message/actions/optimisticUpdate.ts +260 -0
  123. package/src/store/chat/slices/message/actions/publicApi.ts +224 -0
  124. package/src/store/chat/slices/message/actions/query.ts +120 -0
  125. package/src/store/chat/slices/message/actions/runtimeState.ts +108 -0
  126. package/src/store/chat/slices/message/initialState.ts +13 -0
  127. package/src/store/chat/slices/message/reducer.test.ts +48 -370
  128. package/src/store/chat/slices/message/reducer.ts +17 -81
  129. package/src/store/chat/slices/message/selectors/chat.test.ts +13 -50
  130. package/src/store/chat/slices/message/selectors/chat.ts +78 -242
  131. package/src/store/chat/slices/message/selectors/dbMessage.ts +140 -0
  132. package/src/store/chat/slices/message/selectors/displayMessage.ts +301 -0
  133. package/src/store/chat/slices/message/selectors/messageState.ts +5 -2
  134. package/src/store/chat/slices/plugin/action.test.ts +62 -64
  135. package/src/store/chat/slices/plugin/action.ts +34 -28
  136. package/src/store/chat/slices/thread/action.test.ts +28 -31
  137. package/src/store/chat/slices/thread/action.ts +13 -10
  138. package/src/store/chat/slices/thread/selectors/index.ts +8 -6
  139. package/src/store/chat/slices/topic/reducer.ts +11 -3
  140. package/src/store/chat/store.ts +1 -1
  141. package/src/store/user/slices/preference/selectors/labPrefer.ts +0 -3
  142. package/packages/database/src/models/__tests__/message.grouping.test.ts +0 -812
  143. package/packages/database/src/utils/__tests__/groupMessages.test.ts +0 -1132
  144. package/packages/database/src/utils/groupMessages.ts +0 -361
  145. package/packages/utils/src/tokenizer/client.ts +0 -35
  146. package/packages/utils/src/tokenizer/estimated.ts +0 -4
  147. package/packages/utils/src/tokenizer/server.ts +0 -11
  148. package/packages/utils/src/tokenizer/tokenizer.worker.ts +0 -12
  149. package/src/app/(backend)/webapi/tokenizer/index.test.ts +0 -32
  150. package/src/app/(backend)/webapi/tokenizer/route.ts +0 -8
  151. package/src/features/Conversation/Error/InvalidAccessCode.tsx +0 -79
  152. package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChat.test.ts +0 -975
  153. package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts +0 -1050
  154. package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +0 -720
  155. package/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts +0 -849
  156. package/src/store/chat/slices/message/action.ts +0 -629
@@ -0,0 +1,391 @@
1
+ import { UIChatMessage } from '@lobechat/types';
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { chatService } from '@/services/chat';
6
+ import { messageService } from '@/services/message';
7
+
8
+ import { useChatStore } from '../../../../store';
9
+ import { TEST_CONTENT, TEST_IDS, createMockMessage } from './fixtures';
10
+ import { resetTestEnvironment, setupMockSelectors, spyOnMessageService } from './helpers';
11
+
12
+ // Keep zustand mock as it's needed globally
13
+ vi.mock('zustand/traditional');
14
+
15
+ const realExecAgentRuntime = useChatStore.getState().internal_execAgentRuntime;
16
+
17
+ beforeEach(() => {
18
+ resetTestEnvironment();
19
+ setupMockSelectors();
20
+ spyOnMessageService();
21
+
22
+ act(() => {
23
+ useChatStore.setState({
24
+ refreshMessages: vi.fn(),
25
+ internal_execAgentRuntime: vi.fn(),
26
+ });
27
+ });
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.restoreAllMocks();
32
+ });
33
+
34
+ describe('StreamingExecutor actions', () => {
35
+ describe('internal_fetchAIChatMessage', () => {
36
+ it('should fetch and return AI chat response', async () => {
37
+ const { result } = renderHook(() => useChatStore());
38
+ const messages = [createMockMessage({ role: 'user' })];
39
+
40
+ const streamSpy = vi
41
+ .spyOn(chatService, 'createAssistantMessageStream')
42
+ .mockImplementation(async ({ onMessageHandle, onFinish }) => {
43
+ await onMessageHandle?.({ type: 'text', text: TEST_CONTENT.AI_RESPONSE } as any);
44
+ await onFinish?.(TEST_CONTENT.AI_RESPONSE, {});
45
+ });
46
+
47
+ await act(async () => {
48
+ const response = await result.current.internal_fetchAIChatMessage({
49
+ messages,
50
+ messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
51
+ model: 'gpt-4o-mini',
52
+ provider: 'openai',
53
+ });
54
+ expect(response.isFunctionCall).toEqual(false);
55
+ expect(response.content).toEqual(TEST_CONTENT.AI_RESPONSE);
56
+ });
57
+
58
+ streamSpy.mockRestore();
59
+ });
60
+
61
+ it('should handle streaming errors gracefully', async () => {
62
+ const { result } = renderHook(() => useChatStore());
63
+ const messages = [createMockMessage({ role: 'user' })];
64
+
65
+ const streamSpy = vi
66
+ .spyOn(chatService, 'createAssistantMessageStream')
67
+ .mockImplementation(async ({ onErrorHandle }) => {
68
+ await onErrorHandle?.({ type: 'InvalidProviderAPIKey', message: 'Network error' } as any);
69
+ });
70
+
71
+ const updateMessageErrorSpy = vi.spyOn(messageService, 'updateMessageError');
72
+
73
+ await act(async () => {
74
+ await result.current.internal_fetchAIChatMessage({
75
+ model: 'gpt-4o-mini',
76
+ provider: 'openai',
77
+ messages,
78
+ messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
79
+ });
80
+ });
81
+
82
+ expect(updateMessageErrorSpy).toHaveBeenCalledWith(
83
+ TEST_IDS.ASSISTANT_MESSAGE_ID,
84
+ expect.objectContaining({ type: 'InvalidProviderAPIKey' }),
85
+ );
86
+
87
+ streamSpy.mockRestore();
88
+ });
89
+
90
+ it('should handle tool call chunks during streaming', async () => {
91
+ const { result } = renderHook(() => useChatStore());
92
+ const messages = [createMockMessage({ role: 'user' })];
93
+
94
+ const streamSpy = vi
95
+ .spyOn(chatService, 'createAssistantMessageStream')
96
+ .mockImplementation(async ({ onMessageHandle, onFinish }) => {
97
+ await onMessageHandle?.({
98
+ type: 'tool_calls',
99
+ isAnimationActives: [true],
100
+ tool_calls: [
101
+ { id: 'tool-1', type: 'function', function: { name: 'test', arguments: '{}' } },
102
+ ],
103
+ } as any);
104
+ await onFinish?.(TEST_CONTENT.AI_RESPONSE, {
105
+ toolCalls: [
106
+ { id: 'tool-1', type: 'function', function: { name: 'test', arguments: '{}' } },
107
+ ],
108
+ } as any);
109
+ });
110
+
111
+ await act(async () => {
112
+ const response = await result.current.internal_fetchAIChatMessage({
113
+ messages,
114
+ messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
115
+ model: 'gpt-4o-mini',
116
+ provider: 'openai',
117
+ });
118
+ expect(response.isFunctionCall).toEqual(true);
119
+ });
120
+
121
+ streamSpy.mockRestore();
122
+ });
123
+
124
+ it('should handle text chunks during streaming', async () => {
125
+ const { result } = renderHook(() => useChatStore());
126
+ const messages = [createMockMessage({ role: 'user' })];
127
+ const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
128
+
129
+ const streamSpy = vi
130
+ .spyOn(chatService, 'createAssistantMessageStream')
131
+ .mockImplementation(async ({ onMessageHandle, onFinish }) => {
132
+ await onMessageHandle?.({ type: 'text', text: 'Hello' } as any);
133
+ await onMessageHandle?.({ type: 'text', text: ' World' } as any);
134
+ await onFinish?.('Hello World', {} as any);
135
+ });
136
+
137
+ await act(async () => {
138
+ await result.current.internal_fetchAIChatMessage({
139
+ messages,
140
+ messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
141
+ model: 'gpt-4o-mini',
142
+ provider: 'openai',
143
+ });
144
+ });
145
+
146
+ expect(dispatchSpy).toHaveBeenCalledWith(
147
+ expect.objectContaining({
148
+ id: TEST_IDS.ASSISTANT_MESSAGE_ID,
149
+ type: 'updateMessage',
150
+ value: expect.objectContaining({ content: 'Hello' }),
151
+ }),
152
+ );
153
+
154
+ streamSpy.mockRestore();
155
+ });
156
+
157
+ it('should handle reasoning chunks during streaming', async () => {
158
+ const { result } = renderHook(() => useChatStore());
159
+ const messages = [createMockMessage({ role: 'user' })];
160
+ const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
161
+
162
+ const streamSpy = vi
163
+ .spyOn(chatService, 'createAssistantMessageStream')
164
+ .mockImplementation(async ({ onMessageHandle, onFinish }) => {
165
+ await onMessageHandle?.({ type: 'reasoning', text: 'Thinking...' } as any);
166
+ await onMessageHandle?.({ type: 'text', text: 'Answer' } as any);
167
+ await onFinish?.('Answer', {} as any);
168
+ });
169
+
170
+ await act(async () => {
171
+ await result.current.internal_fetchAIChatMessage({
172
+ messages,
173
+ messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
174
+ model: 'gpt-4o-mini',
175
+ provider: 'openai',
176
+ });
177
+ });
178
+
179
+ expect(dispatchSpy).toHaveBeenCalledWith(
180
+ expect.objectContaining({
181
+ id: TEST_IDS.ASSISTANT_MESSAGE_ID,
182
+ type: 'updateMessage',
183
+ value: expect.objectContaining({ reasoning: { content: 'Thinking...' } }),
184
+ }),
185
+ );
186
+
187
+ streamSpy.mockRestore();
188
+ });
189
+
190
+ it('should skip grounding when citations are empty', async () => {
191
+ const { result } = renderHook(() => useChatStore());
192
+ const messages = [createMockMessage({ role: 'user' })];
193
+ const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
194
+
195
+ const streamSpy = vi
196
+ .spyOn(chatService, 'createAssistantMessageStream')
197
+ .mockImplementation(async ({ onMessageHandle, onFinish }) => {
198
+ await onMessageHandle?.({
199
+ type: 'grounding',
200
+ grounding: { citations: [], searchQueries: [] },
201
+ } as any);
202
+ await onFinish?.('Answer', {} as any);
203
+ });
204
+
205
+ await act(async () => {
206
+ await result.current.internal_fetchAIChatMessage({
207
+ messages,
208
+ messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
209
+ model: 'gpt-4o-mini',
210
+ provider: 'openai',
211
+ });
212
+ });
213
+
214
+ // Should not dispatch when citations are empty
215
+ const groundingCalls = dispatchSpy.mock.calls.filter((call) => {
216
+ const dispatch = call[0];
217
+ return dispatch?.type === 'updateMessage' && 'value' in dispatch && dispatch.value?.search;
218
+ });
219
+ expect(groundingCalls).toHaveLength(0);
220
+
221
+ streamSpy.mockRestore();
222
+ });
223
+
224
+ it('should handle grounding chunks during streaming', async () => {
225
+ const { result } = renderHook(() => useChatStore());
226
+ const messages = [createMockMessage({ role: 'user' })];
227
+ const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
228
+
229
+ const streamSpy = vi
230
+ .spyOn(chatService, 'createAssistantMessageStream')
231
+ .mockImplementation(async ({ onMessageHandle, onFinish }) => {
232
+ await onMessageHandle?.({
233
+ type: 'grounding',
234
+ grounding: {
235
+ citations: [{ url: 'https://example.com', title: 'Example' }],
236
+ searchQueries: ['test query'],
237
+ },
238
+ } as any);
239
+ await onFinish?.('Answer', {} as any);
240
+ });
241
+
242
+ await act(async () => {
243
+ await result.current.internal_fetchAIChatMessage({
244
+ messages,
245
+ messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
246
+ model: 'gpt-4o-mini',
247
+ provider: 'openai',
248
+ });
249
+ });
250
+
251
+ expect(dispatchSpy).toHaveBeenCalledWith(
252
+ expect.objectContaining({
253
+ id: TEST_IDS.ASSISTANT_MESSAGE_ID,
254
+ type: 'updateMessage',
255
+ value: expect.objectContaining({
256
+ search: expect.objectContaining({
257
+ citations: expect.any(Array),
258
+ }),
259
+ }),
260
+ }),
261
+ );
262
+
263
+ streamSpy.mockRestore();
264
+ });
265
+
266
+ it('should handle base64 image chunks during streaming', async () => {
267
+ const { result } = renderHook(() => useChatStore());
268
+ const messages = [createMockMessage({ role: 'user' })];
269
+ const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
270
+
271
+ const streamSpy = vi
272
+ .spyOn(chatService, 'createAssistantMessageStream')
273
+ .mockImplementation(async ({ onMessageHandle, onFinish }) => {
274
+ await onMessageHandle?.({
275
+ type: 'base64_image',
276
+ image: { id: 'img-1', data: 'base64data' },
277
+ images: [{ id: 'img-1', data: 'base64data' }],
278
+ } as any);
279
+ await onFinish?.('Answer', {} as any);
280
+ });
281
+
282
+ await act(async () => {
283
+ await result.current.internal_fetchAIChatMessage({
284
+ messages,
285
+ messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
286
+ model: 'gpt-4o-mini',
287
+ provider: 'openai',
288
+ });
289
+ });
290
+
291
+ expect(dispatchSpy).toHaveBeenCalledWith(
292
+ expect.objectContaining({
293
+ id: TEST_IDS.ASSISTANT_MESSAGE_ID,
294
+ type: 'updateMessage',
295
+ value: expect.objectContaining({
296
+ imageList: expect.any(Array),
297
+ }),
298
+ }),
299
+ );
300
+
301
+ streamSpy.mockRestore();
302
+ });
303
+
304
+ it('should handle empty tool call arguments', async () => {
305
+ const { result } = renderHook(() => useChatStore());
306
+ const messages = [createMockMessage({ role: 'user' })];
307
+
308
+ const streamSpy = vi
309
+ .spyOn(chatService, 'createAssistantMessageStream')
310
+ .mockImplementation(async ({ onFinish }) => {
311
+ await onFinish?.(TEST_CONTENT.AI_RESPONSE, {
312
+ toolCalls: [
313
+ { id: 'tool-1', type: 'function', function: { name: 'test', arguments: '' } },
314
+ ],
315
+ } as any);
316
+ });
317
+
318
+ await act(async () => {
319
+ const response = await result.current.internal_fetchAIChatMessage({
320
+ messages,
321
+ messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
322
+ model: 'gpt-4o-mini',
323
+ provider: 'openai',
324
+ });
325
+ expect(response.isFunctionCall).toEqual(true);
326
+ });
327
+
328
+ streamSpy.mockRestore();
329
+ });
330
+
331
+ it('should update message with traceId when provided in onFinish', async () => {
332
+ const { result } = renderHook(() => useChatStore());
333
+ const messages = [createMockMessage({ role: 'user' })];
334
+ const traceId = 'test-trace-123';
335
+
336
+ const updateMessageSpy = vi.spyOn(messageService, 'updateMessage');
337
+ const streamSpy = vi
338
+ .spyOn(chatService, 'createAssistantMessageStream')
339
+ .mockImplementation(async ({ onFinish }) => {
340
+ await onFinish?.(TEST_CONTENT.AI_RESPONSE, { traceId } as any);
341
+ });
342
+
343
+ await act(async () => {
344
+ await result.current.internal_fetchAIChatMessage({
345
+ messages,
346
+ messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
347
+ model: 'gpt-4o-mini',
348
+ provider: 'openai',
349
+ });
350
+ });
351
+
352
+ expect(updateMessageSpy).toHaveBeenCalledWith(
353
+ TEST_IDS.ASSISTANT_MESSAGE_ID,
354
+ expect.objectContaining({ traceId }),
355
+ );
356
+
357
+ streamSpy.mockRestore();
358
+ });
359
+ });
360
+
361
+ describe('internal_execAgentRuntime', () => {
362
+ it('should handle the core AI message processing', async () => {
363
+ act(() => {
364
+ useChatStore.setState({ internal_execAgentRuntime: realExecAgentRuntime });
365
+ });
366
+
367
+ const { result } = renderHook(() => useChatStore());
368
+ const userMessage = {
369
+ id: TEST_IDS.USER_MESSAGE_ID,
370
+ role: 'user',
371
+ content: TEST_CONTENT.USER_MESSAGE,
372
+ sessionId: TEST_IDS.SESSION_ID,
373
+ topicId: TEST_IDS.TOPIC_ID,
374
+ } as UIChatMessage;
375
+ const messages = [userMessage];
376
+
377
+ const streamSpy = vi.spyOn(chatService, 'createAssistantMessageStream');
378
+
379
+ await act(async () => {
380
+ await result.current.internal_execAgentRuntime({
381
+ messages,
382
+ parentMessageId: userMessage.id,
383
+ parentMessageType: 'user',
384
+ });
385
+ });
386
+
387
+ expect(streamSpy).toHaveBeenCalled();
388
+ expect(result.current.refreshMessages).toHaveBeenCalled();
389
+ });
390
+ });
391
+ });
@@ -0,0 +1,179 @@
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 { TEST_IDS } from './fixtures';
6
+ import { resetTestEnvironment } from './helpers';
7
+
8
+ // Keep zustand mock as it's needed globally
9
+ vi.mock('zustand/traditional');
10
+
11
+ beforeEach(() => {
12
+ resetTestEnvironment();
13
+ });
14
+
15
+ afterEach(() => {
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ describe('StreamingStates actions', () => {
20
+ describe('internal_toggleChatLoading', () => {
21
+ it('should enable loading state with new abort controller', () => {
22
+ const { result } = renderHook(() => useChatStore());
23
+
24
+ act(() => {
25
+ result.current.internal_toggleChatLoading(true, TEST_IDS.MESSAGE_ID, 'test-action');
26
+ });
27
+
28
+ const state = useChatStore.getState();
29
+ expect(state.chatLoadingIdsAbortController).toBeInstanceOf(AbortController);
30
+ expect(state.chatLoadingIds).toEqual([TEST_IDS.MESSAGE_ID]);
31
+ });
32
+
33
+ it('should disable loading state and clear abort controller', () => {
34
+ const { result } = renderHook(() => useChatStore());
35
+
36
+ act(() => {
37
+ result.current.internal_toggleChatLoading(true, TEST_IDS.MESSAGE_ID, 'start');
38
+ result.current.internal_toggleChatLoading(false, undefined, 'stop');
39
+ });
40
+
41
+ const state = useChatStore.getState();
42
+ expect(state.chatLoadingIdsAbortController).toBeUndefined();
43
+ expect(state.chatLoadingIds).toEqual([]);
44
+ });
45
+
46
+ it('should manage beforeunload event listener', () => {
47
+ const { result } = renderHook(() => useChatStore());
48
+ const addListenerSpy = vi.spyOn(window, 'addEventListener');
49
+ const removeListenerSpy = vi.spyOn(window, 'removeEventListener');
50
+
51
+ act(() => {
52
+ result.current.internal_toggleChatLoading(true, TEST_IDS.MESSAGE_ID, 'start');
53
+ });
54
+
55
+ expect(addListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
56
+
57
+ act(() => {
58
+ result.current.internal_toggleChatLoading(false, undefined, 'stop');
59
+ });
60
+
61
+ expect(removeListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
62
+ });
63
+
64
+ it('should reuse existing abort controller', () => {
65
+ const existingController = new AbortController();
66
+
67
+ act(() => {
68
+ useChatStore.setState({ chatLoadingIdsAbortController: existingController });
69
+ });
70
+
71
+ const { result } = renderHook(() => useChatStore());
72
+
73
+ act(() => {
74
+ result.current.internal_toggleChatLoading(true, TEST_IDS.MESSAGE_ID, 'test');
75
+ });
76
+
77
+ const state = useChatStore.getState();
78
+ expect(state.chatLoadingIdsAbortController).toStrictEqual(existingController);
79
+ });
80
+ });
81
+
82
+ describe('internal_toggleToolCallingStreaming', () => {
83
+ it('should track tool calling stream status', () => {
84
+ const { result } = renderHook(() => useChatStore());
85
+
86
+ act(() => {
87
+ result.current.internal_toggleToolCallingStreaming(TEST_IDS.MESSAGE_ID, [true]);
88
+ });
89
+
90
+ expect(result.current.toolCallingStreamIds[TEST_IDS.MESSAGE_ID]).toEqual([true]);
91
+ });
92
+
93
+ it('should clear tool calling stream status', () => {
94
+ const { result } = renderHook(() => useChatStore());
95
+
96
+ act(() => {
97
+ result.current.internal_toggleToolCallingStreaming(TEST_IDS.MESSAGE_ID, [true]);
98
+ result.current.internal_toggleToolCallingStreaming(TEST_IDS.MESSAGE_ID, undefined);
99
+ });
100
+
101
+ expect(result.current.toolCallingStreamIds[TEST_IDS.MESSAGE_ID]).toBeUndefined();
102
+ });
103
+ });
104
+
105
+ describe('internal_toggleSearchWorkflow', () => {
106
+ it('should enable search workflow loading state', () => {
107
+ const { result } = renderHook(() => useChatStore());
108
+
109
+ act(() => {
110
+ result.current.internal_toggleSearchWorkflow(true, TEST_IDS.MESSAGE_ID);
111
+ });
112
+
113
+ const state = useChatStore.getState();
114
+ expect(state.searchWorkflowLoadingIds).toEqual([TEST_IDS.MESSAGE_ID]);
115
+ });
116
+
117
+ it('should disable search workflow loading state', () => {
118
+ const { result } = renderHook(() => useChatStore());
119
+
120
+ act(() => {
121
+ result.current.internal_toggleSearchWorkflow(true, TEST_IDS.MESSAGE_ID);
122
+ result.current.internal_toggleSearchWorkflow(false, TEST_IDS.MESSAGE_ID);
123
+ });
124
+
125
+ const state = useChatStore.getState();
126
+ expect(state.searchWorkflowLoadingIds).toEqual([]);
127
+ });
128
+ });
129
+
130
+ describe('internal_toggleChatReasoning', () => {
131
+ it('should enable reasoning loading state', () => {
132
+ const { result } = renderHook(() => useChatStore());
133
+
134
+ act(() => {
135
+ result.current.internal_toggleChatReasoning(true, TEST_IDS.MESSAGE_ID, 'test-action');
136
+ });
137
+
138
+ const state = useChatStore.getState();
139
+ expect(state.reasoningLoadingIds).toEqual([TEST_IDS.MESSAGE_ID]);
140
+ });
141
+
142
+ it('should disable reasoning loading state', () => {
143
+ const { result } = renderHook(() => useChatStore());
144
+
145
+ act(() => {
146
+ result.current.internal_toggleChatReasoning(true, TEST_IDS.MESSAGE_ID, 'start');
147
+ result.current.internal_toggleChatReasoning(false, TEST_IDS.MESSAGE_ID, 'stop');
148
+ });
149
+
150
+ const state = useChatStore.getState();
151
+ expect(state.reasoningLoadingIds).toEqual([]);
152
+ });
153
+ });
154
+
155
+ describe('internal_toggleMessageInToolsCalling', () => {
156
+ it('should enable tools calling state', () => {
157
+ const { result } = renderHook(() => useChatStore());
158
+
159
+ act(() => {
160
+ result.current.internal_toggleMessageInToolsCalling(true, TEST_IDS.MESSAGE_ID);
161
+ });
162
+
163
+ const state = useChatStore.getState();
164
+ expect(state.messageInToolsCallingIds).toEqual([TEST_IDS.MESSAGE_ID]);
165
+ });
166
+
167
+ it('should disable tools calling state', () => {
168
+ const { result } = renderHook(() => useChatStore());
169
+
170
+ act(() => {
171
+ result.current.internal_toggleMessageInToolsCalling(true, TEST_IDS.MESSAGE_ID);
172
+ result.current.internal_toggleMessageInToolsCalling(false, TEST_IDS.MESSAGE_ID);
173
+ });
174
+
175
+ const state = useChatStore.getState();
176
+ expect(state.messageInToolsCallingIds).toEqual([]);
177
+ });
178
+ });
179
+ });