@lobehub/lobehub 2.0.0-next.78 → 2.0.0-next.79
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.
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +1 -1
- package/packages/agent-runtime/src/types/instruction.ts +7 -2
- package/packages/database/src/utils/genWhere.test.ts +243 -0
- package/src/app/[variants]/(main)/discover/(list)/assistant/features/Category/index.tsx +1 -1
- package/src/app/[variants]/(main)/discover/(list)/mcp/features/Category/index.tsx +1 -1
- package/src/app/[variants]/(main)/discover/(list)/model/features/Category/index.tsx +1 -1
- package/src/store/chat/initialState.ts +4 -1
- package/src/store/chat/slices/operation/__tests__/actions.test.ts +353 -0
- package/src/store/chat/slices/operation/__tests__/selectors.test.ts +273 -0
- package/src/store/chat/slices/operation/actions.ts +451 -0
- package/src/store/chat/slices/operation/index.ts +4 -0
- package/src/store/chat/slices/operation/initialState.ts +44 -0
- package/src/store/chat/slices/operation/selectors.ts +246 -0
- package/src/store/chat/slices/operation/types.ts +134 -0
- package/src/store/chat/store.ts +4 -1
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useChatStore } from '@/store/chat/store';
|
|
5
|
+
|
|
6
|
+
describe('Operation Actions', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
useChatStore.setState(useChatStore.getInitialState());
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.clearAllTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('startOperation', () => {
|
|
16
|
+
it('should create a new operation with correct initial state', () => {
|
|
17
|
+
const { result } = renderHook(() => useChatStore());
|
|
18
|
+
|
|
19
|
+
let operationId: string;
|
|
20
|
+
let abortController: AbortController;
|
|
21
|
+
|
|
22
|
+
act(() => {
|
|
23
|
+
const res = result.current.startOperation({
|
|
24
|
+
type: 'generateAI',
|
|
25
|
+
context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
|
|
26
|
+
label: 'Generating...',
|
|
27
|
+
});
|
|
28
|
+
operationId = res.operationId;
|
|
29
|
+
abortController = res.abortController;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const operation = result.current.operations[operationId!];
|
|
33
|
+
|
|
34
|
+
expect(operation).toBeDefined();
|
|
35
|
+
expect(operation.type).toBe('generateAI');
|
|
36
|
+
expect(operation.status).toBe('running');
|
|
37
|
+
expect(operation.context.sessionId).toBe('session1');
|
|
38
|
+
expect(operation.context.topicId).toBe('topic1');
|
|
39
|
+
expect(operation.context.messageId).toBe('msg1');
|
|
40
|
+
expect(operation.label).toBe('Generating...');
|
|
41
|
+
expect(operation.abortController).toBe(abortController!);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should inherit context from parent operation', () => {
|
|
45
|
+
const { result } = renderHook(() => useChatStore());
|
|
46
|
+
|
|
47
|
+
let parentOpId: string;
|
|
48
|
+
let childOpId: string;
|
|
49
|
+
|
|
50
|
+
act(() => {
|
|
51
|
+
// Create parent operation
|
|
52
|
+
const parent = result.current.startOperation({
|
|
53
|
+
type: 'sendMessage',
|
|
54
|
+
context: { sessionId: 'session1', topicId: 'topic1' },
|
|
55
|
+
});
|
|
56
|
+
parentOpId = parent.operationId;
|
|
57
|
+
|
|
58
|
+
// Create child operation (inherits context)
|
|
59
|
+
const child = result.current.startOperation({
|
|
60
|
+
type: 'generateAI',
|
|
61
|
+
context: { messageId: 'msg1' }, // Only override messageId
|
|
62
|
+
parentOperationId: parentOpId,
|
|
63
|
+
});
|
|
64
|
+
childOpId = child.operationId;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const childOp = result.current.operations[childOpId!];
|
|
68
|
+
|
|
69
|
+
expect(childOp.context.sessionId).toBe('session1'); // Inherited
|
|
70
|
+
expect(childOp.context.topicId).toBe('topic1'); // Inherited
|
|
71
|
+
expect(childOp.context.messageId).toBe('msg1'); // Overridden
|
|
72
|
+
expect(childOp.parentOperationId).toBe(parentOpId!);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should fully inherit parent context when child context is undefined', () => {
|
|
76
|
+
const { result } = renderHook(() => useChatStore());
|
|
77
|
+
|
|
78
|
+
let parentOpId: string;
|
|
79
|
+
let childOpId: string;
|
|
80
|
+
|
|
81
|
+
act(() => {
|
|
82
|
+
// Create parent operation with full context
|
|
83
|
+
const parent = result.current.startOperation({
|
|
84
|
+
type: 'sendMessage',
|
|
85
|
+
context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
|
|
86
|
+
});
|
|
87
|
+
parentOpId = parent.operationId;
|
|
88
|
+
|
|
89
|
+
// Create child operation without context (undefined)
|
|
90
|
+
const child = result.current.startOperation({
|
|
91
|
+
type: 'generateAI',
|
|
92
|
+
parentOperationId: parentOpId,
|
|
93
|
+
});
|
|
94
|
+
childOpId = child.operationId;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const childOp = result.current.operations[childOpId!];
|
|
98
|
+
|
|
99
|
+
// Should fully inherit parent's context
|
|
100
|
+
expect(childOp.context.sessionId).toBe('session1');
|
|
101
|
+
expect(childOp.context.topicId).toBe('topic1');
|
|
102
|
+
expect(childOp.context.messageId).toBe('msg1');
|
|
103
|
+
expect(childOp.parentOperationId).toBe(parentOpId!);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should update indexes correctly', () => {
|
|
107
|
+
const { result } = renderHook(() => useChatStore());
|
|
108
|
+
|
|
109
|
+
let operationId: string;
|
|
110
|
+
|
|
111
|
+
act(() => {
|
|
112
|
+
operationId = result.current.startOperation({
|
|
113
|
+
type: 'generateAI',
|
|
114
|
+
context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
|
|
115
|
+
}).operationId;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Check type index
|
|
119
|
+
expect(result.current.operationsByType.generateAI).toContain(operationId!);
|
|
120
|
+
|
|
121
|
+
// Check message index
|
|
122
|
+
expect(result.current.operationsByMessage.msg1).toContain(operationId!);
|
|
123
|
+
|
|
124
|
+
// Check context index
|
|
125
|
+
const contextKey = 'session1_topic1';
|
|
126
|
+
expect(result.current.operationsByContext[contextKey]).toContain(operationId!);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('completeOperation', () => {
|
|
131
|
+
it('should mark operation as completed with correct metadata', () => {
|
|
132
|
+
const { result } = renderHook(() => useChatStore());
|
|
133
|
+
|
|
134
|
+
let operationId: string;
|
|
135
|
+
|
|
136
|
+
act(() => {
|
|
137
|
+
operationId = result.current.startOperation({
|
|
138
|
+
type: 'generateAI',
|
|
139
|
+
context: { sessionId: 'session1' },
|
|
140
|
+
}).operationId;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const startTime = result.current.operations[operationId!].metadata.startTime;
|
|
144
|
+
|
|
145
|
+
act(() => {
|
|
146
|
+
result.current.completeOperation(operationId!);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const operation = result.current.operations[operationId!];
|
|
150
|
+
|
|
151
|
+
expect(operation.status).toBe('completed');
|
|
152
|
+
expect(operation.metadata.endTime).toBeDefined();
|
|
153
|
+
expect(operation.metadata.duration).toBeDefined();
|
|
154
|
+
expect(operation.metadata.duration).toBeGreaterThanOrEqual(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('cancelOperation', () => {
|
|
159
|
+
it('should cancel operation and abort controller', () => {
|
|
160
|
+
const { result } = renderHook(() => useChatStore());
|
|
161
|
+
|
|
162
|
+
let operationId: string;
|
|
163
|
+
let abortController: AbortController;
|
|
164
|
+
|
|
165
|
+
act(() => {
|
|
166
|
+
const res = result.current.startOperation({
|
|
167
|
+
type: 'generateAI',
|
|
168
|
+
context: { sessionId: 'session1' },
|
|
169
|
+
});
|
|
170
|
+
operationId = res.operationId;
|
|
171
|
+
abortController = res.abortController;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
act(() => {
|
|
175
|
+
result.current.cancelOperation(operationId!, 'User cancelled');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const operation = result.current.operations[operationId!];
|
|
179
|
+
|
|
180
|
+
expect(operation.status).toBe('cancelled');
|
|
181
|
+
expect(operation.metadata.cancelReason).toBe('User cancelled');
|
|
182
|
+
expect(abortController!.signal.aborted).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should recursively cancel child operations', () => {
|
|
186
|
+
const { result } = renderHook(() => useChatStore());
|
|
187
|
+
|
|
188
|
+
let parentOpId: string;
|
|
189
|
+
let child1OpId: string;
|
|
190
|
+
let child2OpId: string;
|
|
191
|
+
|
|
192
|
+
act(() => {
|
|
193
|
+
parentOpId = result.current.startOperation({
|
|
194
|
+
type: 'generateAI',
|
|
195
|
+
context: { sessionId: 'session1' },
|
|
196
|
+
}).operationId;
|
|
197
|
+
|
|
198
|
+
child1OpId = result.current.startOperation({
|
|
199
|
+
type: 'reasoning',
|
|
200
|
+
parentOperationId: parentOpId,
|
|
201
|
+
}).operationId;
|
|
202
|
+
|
|
203
|
+
child2OpId = result.current.startOperation({
|
|
204
|
+
type: 'toolCalling',
|
|
205
|
+
parentOperationId: parentOpId,
|
|
206
|
+
}).operationId;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
act(() => {
|
|
210
|
+
result.current.cancelOperation(parentOpId!);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(result.current.operations[parentOpId!].status).toBe('cancelled');
|
|
214
|
+
expect(result.current.operations[child1OpId!].status).toBe('cancelled');
|
|
215
|
+
expect(result.current.operations[child2OpId!].status).toBe('cancelled');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('failOperation', () => {
|
|
220
|
+
it('should mark operation as failed with error', () => {
|
|
221
|
+
const { result } = renderHook(() => useChatStore());
|
|
222
|
+
|
|
223
|
+
let operationId: string;
|
|
224
|
+
|
|
225
|
+
act(() => {
|
|
226
|
+
operationId = result.current.startOperation({
|
|
227
|
+
type: 'generateAI',
|
|
228
|
+
context: { sessionId: 'session1' },
|
|
229
|
+
}).operationId;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
act(() => {
|
|
233
|
+
result.current.failOperation(operationId!, {
|
|
234
|
+
type: 'NetworkError',
|
|
235
|
+
message: 'Connection failed',
|
|
236
|
+
code: 'ERR_NETWORK',
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const operation = result.current.operations[operationId!];
|
|
241
|
+
|
|
242
|
+
expect(operation.status).toBe('failed');
|
|
243
|
+
expect(operation.metadata.error).toEqual({
|
|
244
|
+
type: 'NetworkError',
|
|
245
|
+
message: 'Connection failed',
|
|
246
|
+
code: 'ERR_NETWORK',
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('cancelOperations (batch)', () => {
|
|
252
|
+
it('should cancel operations matching filter', () => {
|
|
253
|
+
const { result } = renderHook(() => useChatStore());
|
|
254
|
+
|
|
255
|
+
let op1: string;
|
|
256
|
+
let op2: string;
|
|
257
|
+
let op3: string;
|
|
258
|
+
|
|
259
|
+
act(() => {
|
|
260
|
+
op1 = result.current.startOperation({
|
|
261
|
+
type: 'generateAI',
|
|
262
|
+
context: { sessionId: 'session1' },
|
|
263
|
+
}).operationId;
|
|
264
|
+
|
|
265
|
+
op2 = result.current.startOperation({
|
|
266
|
+
type: 'generateAI',
|
|
267
|
+
context: { sessionId: 'session1' },
|
|
268
|
+
}).operationId;
|
|
269
|
+
|
|
270
|
+
op3 = result.current.startOperation({
|
|
271
|
+
type: 'reasoning',
|
|
272
|
+
context: { sessionId: 'session1' },
|
|
273
|
+
}).operationId;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
act(() => {
|
|
277
|
+
const cancelled = result.current.cancelOperations({ type: 'generateAI' });
|
|
278
|
+
expect(cancelled).toHaveLength(2);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(result.current.operations[op1!].status).toBe('cancelled');
|
|
282
|
+
expect(result.current.operations[op2!].status).toBe('cancelled');
|
|
283
|
+
expect(result.current.operations[op3!].status).toBe('running'); // Not cancelled
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('associateMessageWithOperation', () => {
|
|
288
|
+
it('should create message-operation mapping', () => {
|
|
289
|
+
const { result } = renderHook(() => useChatStore());
|
|
290
|
+
|
|
291
|
+
let operationId: string;
|
|
292
|
+
|
|
293
|
+
act(() => {
|
|
294
|
+
operationId = result.current.startOperation({
|
|
295
|
+
type: 'generateAI',
|
|
296
|
+
context: { sessionId: 'session1' },
|
|
297
|
+
}).operationId;
|
|
298
|
+
|
|
299
|
+
result.current.associateMessageWithOperation('msg1', operationId!);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
expect(result.current.messageOperationMap.msg1).toBe(operationId!);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('cleanupCompletedOperations', () => {
|
|
307
|
+
it('should remove old completed operations', () => {
|
|
308
|
+
const { result } = renderHook(() => useChatStore());
|
|
309
|
+
|
|
310
|
+
let op1: string;
|
|
311
|
+
let op2: string;
|
|
312
|
+
|
|
313
|
+
act(() => {
|
|
314
|
+
op1 = result.current.startOperation({
|
|
315
|
+
type: 'generateAI',
|
|
316
|
+
context: { sessionId: 'session1' },
|
|
317
|
+
}).operationId;
|
|
318
|
+
|
|
319
|
+
op2 = result.current.startOperation({
|
|
320
|
+
type: 'generateAI',
|
|
321
|
+
context: { sessionId: 'session1' },
|
|
322
|
+
}).operationId;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
act(() => {
|
|
326
|
+
result.current.completeOperation(op1!);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Manually set endTime to past
|
|
330
|
+
act(() => {
|
|
331
|
+
useChatStore.setState({
|
|
332
|
+
operations: {
|
|
333
|
+
...result.current.operations,
|
|
334
|
+
[op1!]: {
|
|
335
|
+
...result.current.operations[op1!],
|
|
336
|
+
metadata: {
|
|
337
|
+
...result.current.operations[op1!].metadata,
|
|
338
|
+
endTime: Date.now() - 120000, // 2 minutes ago
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
act(() => {
|
|
346
|
+
result.current.cleanupCompletedOperations(60000); // 1 minute threshold
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(result.current.operations[op1!]).toBeUndefined(); // Cleaned up
|
|
350
|
+
expect(result.current.operations[op2!]).toBeDefined(); // Still running
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useChatStore } from '@/store/chat/store';
|
|
5
|
+
|
|
6
|
+
import { operationSelectors } from '../selectors';
|
|
7
|
+
|
|
8
|
+
describe('Operation Selectors', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
useChatStore.setState(useChatStore.getInitialState());
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('getOperationsByType', () => {
|
|
14
|
+
it('should return operations of specific type', () => {
|
|
15
|
+
const { result } = renderHook(() => useChatStore());
|
|
16
|
+
|
|
17
|
+
act(() => {
|
|
18
|
+
result.current.startOperation({
|
|
19
|
+
type: 'generateAI',
|
|
20
|
+
context: { sessionId: 'session1' },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
result.current.startOperation({
|
|
24
|
+
type: 'generateAI',
|
|
25
|
+
context: { sessionId: 'session1' },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
result.current.startOperation({
|
|
29
|
+
type: 'reasoning',
|
|
30
|
+
context: { sessionId: 'session1' },
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const generateOps = operationSelectors.getOperationsByType('generateAI')(result.current);
|
|
35
|
+
const reasoningOps = operationSelectors.getOperationsByType('reasoning')(result.current);
|
|
36
|
+
|
|
37
|
+
expect(generateOps).toHaveLength(2);
|
|
38
|
+
expect(reasoningOps).toHaveLength(1);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('getCurrentContextOperations', () => {
|
|
43
|
+
it('should return operations for current active session/topic', () => {
|
|
44
|
+
const { result } = renderHook(() => useChatStore());
|
|
45
|
+
|
|
46
|
+
act(() => {
|
|
47
|
+
// Set active session and topic
|
|
48
|
+
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
|
|
49
|
+
|
|
50
|
+
result.current.startOperation({
|
|
51
|
+
type: 'generateAI',
|
|
52
|
+
context: { sessionId: 'session1', topicId: 'topic1' },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
result.current.startOperation({
|
|
56
|
+
type: 'reasoning',
|
|
57
|
+
context: { sessionId: 'session1', topicId: 'topic1' },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Operation in different context
|
|
61
|
+
result.current.startOperation({
|
|
62
|
+
type: 'generateAI',
|
|
63
|
+
context: { sessionId: 'session2', topicId: 'topic2' },
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const currentOps = operationSelectors.getCurrentContextOperations(result.current);
|
|
68
|
+
|
|
69
|
+
expect(currentOps).toHaveLength(2);
|
|
70
|
+
expect(currentOps.every((op) => op.context.sessionId === 'session1')).toBe(true);
|
|
71
|
+
expect(currentOps.every((op) => op.context.topicId === 'topic1')).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('hasAnyRunningOperation', () => {
|
|
76
|
+
it('should return true if any operation is running', () => {
|
|
77
|
+
const { result } = renderHook(() => useChatStore());
|
|
78
|
+
|
|
79
|
+
expect(operationSelectors.hasAnyRunningOperation(result.current)).toBe(false);
|
|
80
|
+
|
|
81
|
+
let opId: string;
|
|
82
|
+
|
|
83
|
+
act(() => {
|
|
84
|
+
opId = result.current.startOperation({
|
|
85
|
+
type: 'generateAI',
|
|
86
|
+
context: { sessionId: 'session1' },
|
|
87
|
+
}).operationId;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(operationSelectors.hasAnyRunningOperation(result.current)).toBe(true);
|
|
91
|
+
|
|
92
|
+
act(() => {
|
|
93
|
+
result.current.completeOperation(opId!);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(operationSelectors.hasAnyRunningOperation(result.current)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('hasRunningOperationType', () => {
|
|
101
|
+
it('should return true if specific type is running', () => {
|
|
102
|
+
const { result } = renderHook(() => useChatStore());
|
|
103
|
+
|
|
104
|
+
act(() => {
|
|
105
|
+
result.current.startOperation({
|
|
106
|
+
type: 'generateAI',
|
|
107
|
+
context: { sessionId: 'session1' },
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(operationSelectors.hasRunningOperationType('generateAI')(result.current)).toBe(true);
|
|
112
|
+
expect(operationSelectors.hasRunningOperationType('reasoning')(result.current)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('canSendMessage', () => {
|
|
117
|
+
it('should return false when operations are running in current context', () => {
|
|
118
|
+
const { result } = renderHook(() => useChatStore());
|
|
119
|
+
|
|
120
|
+
act(() => {
|
|
121
|
+
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(operationSelectors.canSendMessage(result.current)).toBe(true);
|
|
125
|
+
|
|
126
|
+
act(() => {
|
|
127
|
+
result.current.startOperation({
|
|
128
|
+
type: 'generateAI',
|
|
129
|
+
context: { sessionId: 'session1', topicId: 'topic1' },
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(operationSelectors.canSendMessage(result.current)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('canInterrupt', () => {
|
|
138
|
+
it('should return true when operations can be cancelled', () => {
|
|
139
|
+
const { result } = renderHook(() => useChatStore());
|
|
140
|
+
|
|
141
|
+
act(() => {
|
|
142
|
+
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(operationSelectors.canInterrupt(result.current)).toBe(false);
|
|
146
|
+
|
|
147
|
+
act(() => {
|
|
148
|
+
result.current.startOperation({
|
|
149
|
+
type: 'generateAI',
|
|
150
|
+
context: { sessionId: 'session1', topicId: 'topic1' },
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(operationSelectors.canInterrupt(result.current)).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('getCurrentOperationLabel', () => {
|
|
159
|
+
it('should return label of most recent running operation', () => {
|
|
160
|
+
const { result } = renderHook(() => useChatStore());
|
|
161
|
+
|
|
162
|
+
act(() => {
|
|
163
|
+
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
|
|
164
|
+
|
|
165
|
+
result.current.startOperation({
|
|
166
|
+
type: 'generateAI',
|
|
167
|
+
context: { sessionId: 'session1', topicId: 'topic1' },
|
|
168
|
+
label: 'Generating response...',
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Simulate some time passing
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
result.current.startOperation({
|
|
174
|
+
type: 'reasoning',
|
|
175
|
+
context: { sessionId: 'session1', topicId: 'topic1' },
|
|
176
|
+
label: 'Thinking...',
|
|
177
|
+
});
|
|
178
|
+
}, 10);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Should return the most recent operation's label
|
|
182
|
+
const label = operationSelectors.getCurrentOperationLabel(result.current);
|
|
183
|
+
expect(label).toBeTruthy();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('isMessageProcessing', () => {
|
|
188
|
+
it('should return true if message has running operations', () => {
|
|
189
|
+
const { result } = renderHook(() => useChatStore());
|
|
190
|
+
|
|
191
|
+
act(() => {
|
|
192
|
+
result.current.startOperation({
|
|
193
|
+
type: 'generateAI',
|
|
194
|
+
context: { sessionId: 'session1', messageId: 'msg1' },
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(operationSelectors.isMessageProcessing('msg1')(result.current)).toBe(true);
|
|
199
|
+
expect(operationSelectors.isMessageProcessing('msg2')(result.current)).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('getOperationContextFromMessage', () => {
|
|
204
|
+
it('should return operation context from message ID', () => {
|
|
205
|
+
const { result } = renderHook(() => useChatStore());
|
|
206
|
+
|
|
207
|
+
let opId: string;
|
|
208
|
+
|
|
209
|
+
act(() => {
|
|
210
|
+
opId = result.current.startOperation({
|
|
211
|
+
type: 'generateAI',
|
|
212
|
+
context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
|
|
213
|
+
}).operationId;
|
|
214
|
+
|
|
215
|
+
result.current.associateMessageWithOperation('msg1', opId!);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const context = operationSelectors.getOperationContextFromMessage('msg1')(result.current);
|
|
219
|
+
|
|
220
|
+
expect(context).toBeDefined();
|
|
221
|
+
expect(context?.sessionId).toBe('session1');
|
|
222
|
+
expect(context?.topicId).toBe('topic1');
|
|
223
|
+
expect(context?.messageId).toBe('msg1');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('backward compatibility selectors', () => {
|
|
228
|
+
it('isAIGenerating should work', () => {
|
|
229
|
+
const { result } = renderHook(() => useChatStore());
|
|
230
|
+
|
|
231
|
+
expect(operationSelectors.isAIGenerating(result.current)).toBe(false);
|
|
232
|
+
|
|
233
|
+
act(() => {
|
|
234
|
+
result.current.startOperation({
|
|
235
|
+
type: 'generateAI',
|
|
236
|
+
context: { sessionId: 'session1' },
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(operationSelectors.isAIGenerating(result.current)).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('isSendingMessage should work', () => {
|
|
244
|
+
const { result } = renderHook(() => useChatStore());
|
|
245
|
+
|
|
246
|
+
expect(operationSelectors.isSendingMessage(result.current)).toBe(false);
|
|
247
|
+
|
|
248
|
+
act(() => {
|
|
249
|
+
result.current.startOperation({
|
|
250
|
+
type: 'sendMessage',
|
|
251
|
+
context: { sessionId: 'session1' },
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(operationSelectors.isSendingMessage(result.current)).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('isInRAGFlow should work', () => {
|
|
259
|
+
const { result } = renderHook(() => useChatStore());
|
|
260
|
+
|
|
261
|
+
expect(operationSelectors.isInRAGFlow(result.current)).toBe(false);
|
|
262
|
+
|
|
263
|
+
act(() => {
|
|
264
|
+
result.current.startOperation({
|
|
265
|
+
type: 'rag',
|
|
266
|
+
context: { sessionId: 'session1' },
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(operationSelectors.isInRAGFlow(result.current)).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
});
|