@lobehub/lobehub 2.0.0-next.84 → 2.0.0-next.86

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 (89) 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/app/[variants]/(main)/discover/(list)/features/Pagination.tsx +1 -1
  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/Messages/Group/Tool/Inspector/index.tsx +1 -1
  24. package/src/features/Conversation/Messages/User/index.tsx +3 -3
  25. package/src/features/Conversation/Messages/index.tsx +3 -3
  26. package/src/features/Conversation/components/AutoScroll.tsx +2 -2
  27. package/src/services/search.ts +2 -2
  28. package/src/store/chat/agents/GeneralChatAgent.ts +98 -0
  29. package/src/store/chat/agents/__tests__/GeneralChatAgent.test.ts +366 -0
  30. package/src/store/chat/agents/__tests__/createAgentExecutors/call-llm.test.ts +1217 -0
  31. package/src/store/chat/agents/__tests__/createAgentExecutors/call-tool.test.ts +1976 -0
  32. package/src/store/chat/agents/__tests__/createAgentExecutors/finish.test.ts +453 -0
  33. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/index.ts +4 -0
  34. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockInstructions.ts +126 -0
  35. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockMessages.ts +94 -0
  36. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockOperations.ts +96 -0
  37. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockStore.ts +138 -0
  38. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/assertions.ts +185 -0
  39. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/index.ts +3 -0
  40. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/operationTestUtils.ts +94 -0
  41. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/testExecutor.ts +139 -0
  42. package/src/store/chat/agents/__tests__/createAgentExecutors/request-human-approve.test.ts +545 -0
  43. package/src/store/chat/agents/__tests__/createAgentExecutors/resolve-aborted-tools.test.ts +686 -0
  44. package/src/store/chat/agents/createAgentExecutors.ts +313 -80
  45. package/src/store/chat/selectors.ts +1 -0
  46. package/src/store/chat/slices/aiChat/__tests__/ai-chat.integration.test.ts +667 -0
  47. package/src/store/chat/slices/aiChat/actions/__tests__/cancel-functionality.test.ts +137 -27
  48. package/src/store/chat/slices/aiChat/actions/__tests__/conversationControl.test.ts +163 -125
  49. package/src/store/chat/slices/aiChat/actions/__tests__/conversationLifecycle.test.ts +12 -2
  50. package/src/store/chat/slices/aiChat/actions/__tests__/fixtures.ts +0 -2
  51. package/src/store/chat/slices/aiChat/actions/__tests__/helpers.ts +0 -2
  52. package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +286 -19
  53. package/src/store/chat/slices/aiChat/actions/__tests__/streamingStates.test.ts +0 -112
  54. package/src/store/chat/slices/aiChat/actions/conversationControl.ts +42 -99
  55. package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +90 -57
  56. package/src/store/chat/slices/aiChat/actions/generateAIGroupChat.ts +5 -25
  57. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +220 -98
  58. package/src/store/chat/slices/aiChat/actions/streamingStates.ts +0 -34
  59. package/src/store/chat/slices/aiChat/initialState.ts +0 -28
  60. package/src/store/chat/slices/aiChat/selectors.test.ts +280 -0
  61. package/src/store/chat/slices/aiChat/selectors.ts +31 -7
  62. package/src/store/chat/slices/builtinTool/actions/__tests__/localSystem.test.ts +21 -30
  63. package/src/store/chat/slices/builtinTool/actions/__tests__/search.test.ts +29 -49
  64. package/src/store/chat/slices/builtinTool/actions/interpreter.ts +83 -48
  65. package/src/store/chat/slices/builtinTool/actions/localSystem.ts +78 -28
  66. package/src/store/chat/slices/builtinTool/actions/search.ts +146 -59
  67. package/src/store/chat/slices/builtinTool/selectors.test.ts +258 -0
  68. package/src/store/chat/slices/builtinTool/selectors.ts +25 -4
  69. package/src/store/chat/slices/message/action.test.ts +134 -16
  70. package/src/store/chat/slices/message/actions/internals.ts +33 -7
  71. package/src/store/chat/slices/message/actions/optimisticUpdate.ts +85 -52
  72. package/src/store/chat/slices/message/initialState.ts +0 -10
  73. package/src/store/chat/slices/message/selectors/messageState.ts +34 -12
  74. package/src/store/chat/slices/operation/__tests__/actions.test.ts +712 -16
  75. package/src/store/chat/slices/operation/__tests__/integration.test.ts +342 -0
  76. package/src/store/chat/slices/operation/__tests__/selectors.test.ts +257 -17
  77. package/src/store/chat/slices/operation/actions.ts +218 -11
  78. package/src/store/chat/slices/operation/selectors.ts +135 -6
  79. package/src/store/chat/slices/operation/types.ts +29 -3
  80. package/src/store/chat/slices/plugin/action.test.ts +30 -322
  81. package/src/store/chat/slices/plugin/actions/internals.ts +0 -14
  82. package/src/store/chat/slices/plugin/actions/optimisticUpdate.ts +21 -19
  83. package/src/store/chat/slices/plugin/actions/pluginTypes.ts +45 -27
  84. package/src/store/chat/slices/plugin/actions/publicApi.ts +3 -4
  85. package/src/store/chat/slices/plugin/actions/workflow.ts +0 -55
  86. package/src/store/chat/slices/thread/selectors/index.ts +4 -2
  87. package/src/store/chat/slices/translate/action.ts +54 -41
  88. package/src/tools/web-browsing/ExecutionRuntime/index.ts +5 -2
  89. package/src/tools/web-browsing/Portal/Search/Footer.tsx +11 -9
@@ -0,0 +1,96 @@
1
+ import { nanoid } from '@lobechat/utils';
2
+
3
+ import type { Operation, OperationType } from '@/store/chat/slices/operation/types';
4
+
5
+ /**
6
+ * Create a mock Operation object for testing
7
+ */
8
+ export const createMockOperation = (
9
+ type: OperationType,
10
+ context: Record<string, any> = {},
11
+ overrides: Partial<Operation> = {},
12
+ ): Operation => {
13
+ return {
14
+ abortController: new AbortController(),
15
+ childOperationIds: [],
16
+ context,
17
+ id: `op_${nanoid()}`,
18
+ metadata: {
19
+ startTime: Date.now(),
20
+ },
21
+ status: 'running',
22
+ type,
23
+ ...overrides,
24
+ };
25
+ };
26
+
27
+ /**
28
+ * Create a cancelled operation
29
+ */
30
+ export const createCancelledOperation = (
31
+ type: OperationType,
32
+ context: Record<string, any> = {},
33
+ ): Operation => {
34
+ const operation = createMockOperation(type, context, { status: 'cancelled' });
35
+ operation.abortController.abort();
36
+ operation.metadata.cancelReason = 'Test cancellation';
37
+ return operation;
38
+ };
39
+
40
+ /**
41
+ * Create a completed operation
42
+ */
43
+ export const createCompletedOperation = (
44
+ type: OperationType,
45
+ context: Record<string, any> = {},
46
+ ): Operation => {
47
+ return createMockOperation(type, context, {
48
+ metadata: {
49
+ duration: 1000,
50
+ endTime: Date.now(),
51
+ startTime: Date.now() - 1000,
52
+ },
53
+ status: 'completed',
54
+ });
55
+ };
56
+
57
+ /**
58
+ * Create a failed operation
59
+ */
60
+ export const createFailedOperation = (
61
+ type: OperationType,
62
+ context: Record<string, any> = {},
63
+ // eslint-disable-next-line unicorn/no-object-as-default-parameter
64
+ error: { message: string; type: string } = { message: 'Test error', type: 'TestError' },
65
+ ): Operation => {
66
+ return createMockOperation(type, context, {
67
+ metadata: {
68
+ duration: 1000,
69
+ endTime: Date.now(),
70
+ error,
71
+ startTime: Date.now() - 1000,
72
+ },
73
+ status: 'failed',
74
+ });
75
+ };
76
+
77
+ /**
78
+ * Create an operation tree (parent with children)
79
+ */
80
+ export const createOperationTree = (
81
+ parentType: OperationType,
82
+ childTypes: OperationType[],
83
+ context: Record<string, any> = {},
84
+ ) => {
85
+ const parent = createMockOperation(parentType, context);
86
+
87
+ const children = childTypes.map((childType) =>
88
+ createMockOperation(childType, context, {
89
+ parentOperationId: parent.id,
90
+ }),
91
+ );
92
+
93
+ parent.childOperationIds = children.map((c) => c.id);
94
+
95
+ return { children, parent };
96
+ };
@@ -0,0 +1,138 @@
1
+ import { nanoid } from '@lobechat/utils';
2
+ import { vi } from 'vitest';
3
+
4
+ import type { ChatStore } from '@/store/chat/store';
5
+
6
+ /**
7
+ * Create a mock ChatStore for testing executors
8
+ * All methods are mocked with vi.fn() and can be customized
9
+ */
10
+ export const createMockStore = (overrides: Partial<ChatStore> = {}): ChatStore => {
11
+ const operations: Record<string, any> = {};
12
+ const messageOperationMap: Record<string, string> = {};
13
+ const operationsByMessage: Record<string, string[]> = {};
14
+ const dbMessagesMap: Record<string, any[]> = {};
15
+
16
+ const store = {
17
+ // Other store properties (add as needed)
18
+ activeId: 'test-session',
19
+
20
+ activeTopicId: 'test-topic',
21
+
22
+ associateMessageWithOperation: vi.fn().mockImplementation((messageId, operationId) => {
23
+ messageOperationMap[messageId] = operationId;
24
+
25
+ if (!operationsByMessage[messageId]) {
26
+ operationsByMessage[messageId] = [];
27
+ }
28
+ if (!operationsByMessage[messageId].includes(operationId)) {
29
+ operationsByMessage[messageId].push(operationId);
30
+ }
31
+ }),
32
+
33
+ cancelOperation: vi.fn().mockImplementation((operationId) => {
34
+ if (operations[operationId]) {
35
+ operations[operationId].abortController.abort();
36
+ operations[operationId].status = 'cancelled';
37
+ }
38
+ }),
39
+
40
+ completeOperation: vi.fn().mockImplementation((operationId) => {
41
+ if (operations[operationId]) {
42
+ operations[operationId].status = 'completed';
43
+ operations[operationId].metadata.endTime = Date.now();
44
+ }
45
+ }),
46
+
47
+ // Message state
48
+ dbMessagesMap,
49
+
50
+ failOperation: vi.fn().mockImplementation((operationId, error) => {
51
+ if (operations[operationId]) {
52
+ operations[operationId].status = 'failed';
53
+ operations[operationId].metadata.error = error;
54
+ operations[operationId].metadata.endTime = Date.now();
55
+ }
56
+ }),
57
+
58
+ // AI chat methods
59
+ internal_fetchAIChatMessage: vi.fn().mockResolvedValue(undefined),
60
+
61
+ internal_invokeDifferentTypePlugin: vi.fn().mockResolvedValue({ error: null }),
62
+
63
+ messageOperationMap,
64
+
65
+ onOperationCancel: vi.fn(),
66
+
67
+ // Operation state
68
+ operations,
69
+
70
+ operationsByContext: {},
71
+
72
+ operationsByMessage,
73
+
74
+ operationsByType: {} as any,
75
+
76
+ optimisticAddToolToAssistantMessage: vi.fn().mockResolvedValue(undefined),
77
+
78
+ // Message management methods
79
+ optimisticCreateMessage: vi.fn().mockImplementation(async (params) => {
80
+ const id = nanoid();
81
+ const message = { id, ...params, createdAt: Date.now(), updatedAt: Date.now() };
82
+ return message;
83
+ }),
84
+
85
+ optimisticUpdateMessageContent: vi.fn().mockResolvedValue(undefined),
86
+
87
+ optimisticUpdateMessagePlugin: vi.fn().mockResolvedValue(undefined),
88
+
89
+ optimisticUpdateMessagePluginError: vi.fn().mockResolvedValue(undefined),
90
+
91
+ optimisticUpdatePluginArguments: vi.fn().mockResolvedValue(undefined),
92
+
93
+ optimisticUpdatePluginState: vi.fn().mockResolvedValue(undefined),
94
+
95
+ // Operation management methods
96
+ startOperation: vi.fn().mockImplementation((config) => {
97
+ const operationId = `op_${nanoid()}`;
98
+ const abortController = new AbortController();
99
+
100
+ const operation = {
101
+ abortController,
102
+ childOperationIds: [],
103
+ context: config.context || {},
104
+ id: operationId,
105
+ metadata: config.metadata || { startTime: Date.now() },
106
+ parentOperationId: config.parentOperationId,
107
+ status: 'running',
108
+ type: config.type,
109
+ };
110
+
111
+ operations[operationId] = operation;
112
+
113
+ // Auto-associate message with operation if messageId exists
114
+ if (config.context?.messageId) {
115
+ messageOperationMap[config.context.messageId] = operationId;
116
+
117
+ if (!operationsByMessage[config.context.messageId]) {
118
+ operationsByMessage[config.context.messageId] = [];
119
+ }
120
+ operationsByMessage[config.context.messageId].push(operationId);
121
+ }
122
+
123
+ return { abortController, operationId };
124
+ }),
125
+ updateOperationMetadata: vi.fn().mockImplementation((operationId, metadata) => {
126
+ if (operations[operationId]) {
127
+ operations[operationId].metadata = {
128
+ ...operations[operationId].metadata,
129
+ ...metadata,
130
+ };
131
+ }
132
+ }),
133
+
134
+ ...overrides,
135
+ } as unknown as ChatStore;
136
+
137
+ return store;
138
+ };
@@ -0,0 +1,185 @@
1
+ import { expect } from 'vitest';
2
+
3
+ import type { OperationType } from '@/store/chat/slices/operation/types';
4
+ import type { ChatStore } from '@/store/chat/store';
5
+
6
+ /**
7
+ * Assert that an operation was created with specific type
8
+ */
9
+ export const expectOperationCreated = (mockStore: ChatStore, type: OperationType) => {
10
+ expect(mockStore.startOperation).toHaveBeenCalledWith(
11
+ expect.objectContaining({
12
+ type,
13
+ }),
14
+ );
15
+ };
16
+
17
+ /**
18
+ * Assert that a message was created with specific role
19
+ */
20
+ export const expectMessageCreated = (mockStore: ChatStore, role: 'assistant' | 'tool' | 'user') => {
21
+ expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
22
+ expect.objectContaining({
23
+ role,
24
+ }),
25
+ );
26
+ };
27
+
28
+ /**
29
+ * Assert that a cancel handler was registered
30
+ */
31
+ export const expectCancelHandlerRegistered = (mockStore: ChatStore, operationId?: string) => {
32
+ if (operationId) {
33
+ expect(mockStore.onOperationCancel).toHaveBeenCalledWith(operationId, expect.any(Function));
34
+ } else {
35
+ expect(mockStore.onOperationCancel).toHaveBeenCalled();
36
+ }
37
+ };
38
+
39
+ /**
40
+ * Assert that an operation was completed
41
+ */
42
+ export const expectOperationCompleted = (mockStore: ChatStore, operationId: string) => {
43
+ expect(mockStore.completeOperation).toHaveBeenCalledWith(operationId);
44
+ };
45
+
46
+ /**
47
+ * Assert that an operation was failed
48
+ */
49
+ export const expectOperationFailed = (
50
+ mockStore: ChatStore,
51
+ operationId: string,
52
+ errorType?: string,
53
+ ) => {
54
+ if (errorType) {
55
+ expect(mockStore.failOperation).toHaveBeenCalledWith(
56
+ operationId,
57
+ expect.objectContaining({
58
+ type: errorType,
59
+ }),
60
+ );
61
+ } else {
62
+ expect(mockStore.failOperation).toHaveBeenCalledWith(operationId, expect.any(Object));
63
+ }
64
+ };
65
+
66
+ /**
67
+ * Assert that message content was updated
68
+ */
69
+ export const expectMessageContentUpdated = (
70
+ mockStore: ChatStore,
71
+ messageId: string,
72
+ content?: string,
73
+ ) => {
74
+ if (content) {
75
+ expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith(
76
+ messageId,
77
+ content,
78
+ expect.anything(),
79
+ expect.anything(),
80
+ );
81
+ } else {
82
+ expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith(
83
+ messageId,
84
+ expect.any(String),
85
+ expect.anything(),
86
+ expect.anything(),
87
+ );
88
+ }
89
+ };
90
+
91
+ /**
92
+ * Assert that message plugin was updated
93
+ */
94
+ export const expectMessagePluginUpdated = (
95
+ mockStore: ChatStore,
96
+ messageId: string,
97
+ interventionStatus?: string,
98
+ ) => {
99
+ if (interventionStatus) {
100
+ expect(mockStore.optimisticUpdateMessagePlugin).toHaveBeenCalledWith(
101
+ messageId,
102
+ expect.objectContaining({
103
+ intervention: expect.objectContaining({
104
+ status: interventionStatus,
105
+ }),
106
+ }),
107
+ expect.anything(),
108
+ );
109
+ } else {
110
+ expect(mockStore.optimisticUpdateMessagePlugin).toHaveBeenCalled();
111
+ }
112
+ };
113
+
114
+ /**
115
+ * Assert that internal_fetchAIChatMessage was called with correct params
116
+ */
117
+ export const expectFetchAIChatMessageCalled = (mockStore: ChatStore, messageId?: string) => {
118
+ if (messageId) {
119
+ expect(mockStore.internal_fetchAIChatMessage).toHaveBeenCalledWith(
120
+ messageId,
121
+ expect.anything(),
122
+ );
123
+ } else {
124
+ expect(mockStore.internal_fetchAIChatMessage).toHaveBeenCalled();
125
+ }
126
+ };
127
+
128
+ /**
129
+ * Assert that internal_invokeDifferentTypePlugin was called
130
+ */
131
+ export const expectInvokePluginCalled = (mockStore: ChatStore, messageId?: string) => {
132
+ if (messageId) {
133
+ expect(mockStore.internal_invokeDifferentTypePlugin).toHaveBeenCalledWith(
134
+ messageId,
135
+ expect.anything(),
136
+ );
137
+ } else {
138
+ expect(mockStore.internal_invokeDifferentTypePlugin).toHaveBeenCalled();
139
+ }
140
+ };
141
+
142
+ /**
143
+ * Assert that operation metadata was updated
144
+ */
145
+ export const expectOperationMetadataUpdated = (
146
+ mockStore: ChatStore,
147
+ operationId: string,
148
+ metadata?: Record<string, any>,
149
+ ) => {
150
+ if (metadata) {
151
+ expect(mockStore.updateOperationMetadata).toHaveBeenCalledWith(
152
+ operationId,
153
+ expect.objectContaining(metadata),
154
+ );
155
+ } else {
156
+ expect(mockStore.updateOperationMetadata).toHaveBeenCalledWith(operationId, expect.any(Object));
157
+ }
158
+ };
159
+
160
+ /**
161
+ * Assert executor result structure
162
+ */
163
+ export const expectValidExecutorResult = (result: any) => {
164
+ expect(result).toHaveProperty('events');
165
+ expect(result).toHaveProperty('newState');
166
+ expect(Array.isArray(result.events)).toBe(true);
167
+ expect(result.newState).toBeDefined();
168
+ };
169
+
170
+ /**
171
+ * Assert executor returned specific event type
172
+ */
173
+ export const expectEventType = (result: any, eventType: string) => {
174
+ expect(result.events.some((e: any) => e.type === eventType)).toBe(true);
175
+ };
176
+
177
+ /**
178
+ * Assert executor returned next context
179
+ */
180
+ export const expectNextContext = (result: any, phase?: string) => {
181
+ expect(result.nextContext).toBeDefined();
182
+ if (phase) {
183
+ expect(result.nextContext.phase).toBe(phase);
184
+ }
185
+ };
@@ -0,0 +1,3 @@
1
+ export * from './assertions';
2
+ export * from './operationTestUtils';
3
+ export * from './testExecutor';
@@ -0,0 +1,94 @@
1
+ import type { Operation } from '@/store/chat/slices/operation/types';
2
+
3
+ /**
4
+ * Simulate operation cancellation
5
+ */
6
+ export const simulateOperationCancellation = (
7
+ operation: Operation,
8
+ reason: string = 'Test cancellation',
9
+ ) => {
10
+ operation.abortController.abort();
11
+ operation.status = 'cancelled';
12
+ if (operation.metadata) {
13
+ operation.metadata.cancelReason = reason;
14
+ }
15
+ };
16
+
17
+ /**
18
+ * Simulate cascading cancellation through operation tree
19
+ */
20
+ export const simulateCascadingCancellation = (
21
+ parentOperation: Operation,
22
+ childOperations: Operation[],
23
+ reason: string = 'Parent operation cancelled',
24
+ ) => {
25
+ simulateOperationCancellation(parentOperation, reason);
26
+ childOperations.forEach((child) => simulateOperationCancellation(child, reason));
27
+ };
28
+
29
+ /**
30
+ * Wait for operation status change
31
+ */
32
+ export const waitForOperationStatus = async (
33
+ getOperation: () => Operation | undefined,
34
+ targetStatus: Operation['status'],
35
+ timeout: number = 1000,
36
+ ): Promise<boolean> => {
37
+ const startTime = Date.now();
38
+ while (Date.now() - startTime < timeout) {
39
+ const operation = getOperation();
40
+ if (operation?.status === targetStatus) {
41
+ return true;
42
+ }
43
+ await new Promise((resolve) => {
44
+ setTimeout(resolve, 10);
45
+ });
46
+ }
47
+ return false;
48
+ };
49
+
50
+ /**
51
+ * Verify operation tree structure
52
+ */
53
+ export const verifyOperationTree = (parent: Operation, expectedChildIds: string[]): boolean => {
54
+ if (!parent.childOperationIds) return false;
55
+ if (parent.childOperationIds.length !== expectedChildIds.length) return false;
56
+ return expectedChildIds.every((id) => parent.childOperationIds!.includes(id));
57
+ };
58
+
59
+ /**
60
+ * Get all operations in a tree (parent + all descendants)
61
+ */
62
+ export const getAllOperationsInTree = (
63
+ operations: Record<string, Operation>,
64
+ rootOperationId: string,
65
+ ): Operation[] => {
66
+ const result: Operation[] = [];
67
+ const visited = new Set<string>();
68
+
69
+ const traverse = (operationId: string) => {
70
+ if (visited.has(operationId)) return;
71
+ visited.add(operationId);
72
+
73
+ const operation = operations[operationId];
74
+ if (!operation) return;
75
+
76
+ result.push(operation);
77
+
78
+ if (operation.childOperationIds) {
79
+ operation.childOperationIds.forEach(traverse);
80
+ }
81
+ };
82
+
83
+ traverse(rootOperationId);
84
+ return result;
85
+ };
86
+
87
+ /**
88
+ * Create AbortSignal that aborts after delay
89
+ */
90
+ export const createDelayedAbortSignal = (delayMs: number): AbortSignal => {
91
+ const controller = new AbortController();
92
+ setTimeout(() => controller.abort(), delayMs);
93
+ return controller.signal;
94
+ };
@@ -0,0 +1,139 @@
1
+ import type {
2
+ AgentInstruction,
3
+ AgentState,
4
+ } from '@lobechat/agent-runtime';
5
+
6
+ import { createAgentExecutors } from '@/store/chat/agents/createAgentExecutors';
7
+ import type { OperationType } from '@/store/chat/slices/operation/types';
8
+ import type { ChatStore } from '@/store/chat/store';
9
+
10
+ /**
11
+ * Execute an executor with mock context
12
+ *
13
+ * @example
14
+ * const result = await executeWithMockContext({
15
+ * executor: 'call_llm',
16
+ * instruction: createCallLLMInstruction(),
17
+ * state: createInitialState(),
18
+ * mockStore,
19
+ * context: { operationId: 'op_123', messageKey: 'session_topic', parentId: 'msg_456' }
20
+ * });
21
+ */
22
+ export const executeWithMockContext = async ({
23
+ executor,
24
+ instruction,
25
+ state,
26
+ mockStore,
27
+ context,
28
+ skipCreateFirstMessage = false,
29
+ }: {
30
+ context: {
31
+ messageKey: string;
32
+ operationId: string;
33
+ parentId: string;
34
+ sessionId?: string;
35
+ topicId?: string | null;
36
+ };
37
+ executor: AgentInstruction['type'];
38
+ instruction: AgentInstruction;
39
+ mockStore: ChatStore;
40
+ skipCreateFirstMessage?: boolean;
41
+ state: AgentState;
42
+ }) => {
43
+ // Ensure operation exists in store
44
+ if (!mockStore.operations[context.operationId]) {
45
+ mockStore.operations[context.operationId] = {
46
+ abortController: new AbortController(),
47
+ childOperationIds: [],
48
+ context: {
49
+ messageId: context.parentId,
50
+ sessionId: context.sessionId || 'test-session',
51
+ topicId: context.topicId !== undefined ? context.topicId : 'test-topic',
52
+ },
53
+ id: context.operationId,
54
+ metadata: { startTime: Date.now() },
55
+ status: 'running',
56
+ type: 'execAgentRuntime' as OperationType,
57
+ };
58
+ }
59
+
60
+ // Create executors with mock context
61
+ const executors = createAgentExecutors({
62
+ get: () => mockStore,
63
+ messageKey: context.messageKey,
64
+ operationId: context.operationId,
65
+ parentId: context.parentId,
66
+ skipCreateFirstMessage,
67
+ });
68
+
69
+ const executorFn = executors[executor];
70
+ if (!executorFn) {
71
+ throw new Error(`Executor ${executor} not found`);
72
+ }
73
+
74
+ // Execute
75
+ const result = await executorFn(instruction, state);
76
+
77
+ return result;
78
+ };
79
+
80
+ /**
81
+ * Create initial agent runtime state for testing
82
+ */
83
+ export const createInitialState = (overrides: Partial<AgentState> = {}): AgentState => {
84
+ const defaultState: any = {
85
+ lastModified: new Date().toISOString(),
86
+ messages: [],
87
+ sessionId: 'test-session',
88
+ status: 'running',
89
+ stepCount: 1,
90
+ usage: {
91
+ humanInteraction: {
92
+ approvalRequests: 0,
93
+ promptRequests: 0,
94
+ selectRequests: 0,
95
+ totalWaitingTimeMs: 0,
96
+ },
97
+ llm: {
98
+ apiCalls: 0,
99
+ processingTimeMs: 0,
100
+ tokens: {
101
+ input: 0,
102
+ output: 0,
103
+ total: 0,
104
+ },
105
+ },
106
+ tools: {
107
+ byTool: [],
108
+ totalCalls: 0,
109
+ totalTimeMs: 0,
110
+ },
111
+ },
112
+ };
113
+
114
+ return {
115
+ ...defaultState,
116
+ ...overrides,
117
+ } as AgentState;
118
+ };
119
+
120
+ /**
121
+ * Create a test context object for executor
122
+ */
123
+ export const createTestContext = (
124
+ overrides: {
125
+ messageKey?: string;
126
+ operationId?: string;
127
+ parentId?: string;
128
+ sessionId?: string;
129
+ topicId?: string | null;
130
+ } = {},
131
+ ) => {
132
+ return {
133
+ messageKey:
134
+ overrides.messageKey ||
135
+ `${overrides.sessionId || 'test-session'}_${overrides.topicId !== undefined ? overrides.topicId : 'test-topic'}`,
136
+ operationId: overrides.operationId || 'op_test',
137
+ parentId: overrides.parentId || 'msg_parent',
138
+ };
139
+ };