@lobehub/lobehub 2.0.0-next.85 → 2.0.0-next.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/apps/desktop/src/main/modules/networkProxy/dispatcher.ts +16 -16
- package/apps/desktop/src/main/modules/networkProxy/tester.ts +11 -11
- package/apps/desktop/src/main/modules/networkProxy/urlBuilder.ts +3 -3
- package/apps/desktop/src/main/modules/networkProxy/validator.ts +10 -10
- package/changelog/v1.json +18 -0
- package/package.json +1 -1
- package/packages/agent-runtime/src/core/runtime.ts +36 -1
- package/packages/agent-runtime/src/types/event.ts +1 -0
- package/packages/agent-runtime/src/types/generalAgent.ts +16 -0
- package/packages/agent-runtime/src/types/instruction.ts +30 -0
- package/packages/agent-runtime/src/types/runtime.ts +7 -0
- package/packages/types/src/message/common/metadata.ts +3 -0
- package/packages/types/src/message/common/tools.ts +2 -2
- package/packages/types/src/tool/search/index.ts +8 -2
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/index.tsx +2 -2
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/useSend.ts +7 -2
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/useSend.ts +15 -14
- package/src/app/[variants]/(main)/chat/session/features/SessionListContent/List/Item/index.tsx +2 -2
- package/src/components/Analytics/MainInterfaceTracker.tsx +2 -2
- package/src/features/ChatInput/ActionBar/STT/browser.tsx +2 -2
- package/src/features/ChatInput/ActionBar/STT/openai.tsx +2 -2
- package/src/features/Conversation/MarkdownElements/LobeThinking/Render.tsx +3 -3
- package/src/features/Conversation/MarkdownElements/Thinking/Render.tsx +3 -3
- package/src/features/Conversation/Messages/Group/Tool/Inspector/index.tsx +1 -1
- package/src/features/Conversation/Messages/User/index.tsx +3 -3
- package/src/features/Conversation/Messages/index.tsx +3 -3
- package/src/features/Conversation/components/AutoScroll.tsx +2 -2
- package/src/features/PluginsUI/Render/StandaloneType/Iframe.tsx +3 -3
- package/src/features/Portal/Home/Body/Plugins/ArtifactList/index.tsx +3 -3
- package/src/features/ShareModal/ShareText/index.tsx +3 -3
- package/src/services/search.ts +2 -2
- package/src/store/chat/agents/GeneralChatAgent.ts +98 -0
- package/src/store/chat/agents/__tests__/GeneralChatAgent.test.ts +366 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/call-llm.test.ts +1217 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/call-tool.test.ts +1976 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/finish.test.ts +453 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/index.ts +4 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockInstructions.ts +126 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockMessages.ts +94 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockOperations.ts +96 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockStore.ts +138 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/assertions.ts +185 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/index.ts +3 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/operationTestUtils.ts +94 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/testExecutor.ts +139 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/request-human-approve.test.ts +545 -0
- package/src/store/chat/agents/__tests__/createAgentExecutors/resolve-aborted-tools.test.ts +686 -0
- package/src/store/chat/agents/createAgentExecutors.ts +313 -80
- package/src/store/chat/selectors.ts +1 -0
- package/src/store/chat/slices/aiChat/__tests__/ai-chat.integration.test.ts +667 -0
- package/src/store/chat/slices/aiChat/actions/__tests__/cancel-functionality.test.ts +137 -27
- package/src/store/chat/slices/aiChat/actions/__tests__/conversationControl.test.ts +163 -125
- package/src/store/chat/slices/aiChat/actions/__tests__/conversationLifecycle.test.ts +12 -2
- package/src/store/chat/slices/aiChat/actions/__tests__/fixtures.ts +0 -2
- package/src/store/chat/slices/aiChat/actions/__tests__/helpers.ts +0 -2
- package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +286 -19
- package/src/store/chat/slices/aiChat/actions/__tests__/streamingStates.test.ts +0 -112
- package/src/store/chat/slices/aiChat/actions/conversationControl.ts +42 -99
- package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +90 -57
- package/src/store/chat/slices/aiChat/actions/generateAIGroupChat.ts +5 -25
- package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +220 -98
- package/src/store/chat/slices/aiChat/actions/streamingStates.ts +0 -34
- package/src/store/chat/slices/aiChat/initialState.ts +0 -28
- package/src/store/chat/slices/aiChat/selectors.test.ts +280 -0
- package/src/store/chat/slices/aiChat/selectors.ts +31 -7
- package/src/store/chat/slices/builtinTool/actions/__tests__/localSystem.test.ts +21 -30
- package/src/store/chat/slices/builtinTool/actions/__tests__/search.test.ts +29 -49
- package/src/store/chat/slices/builtinTool/actions/interpreter.ts +83 -48
- package/src/store/chat/slices/builtinTool/actions/localSystem.ts +78 -28
- package/src/store/chat/slices/builtinTool/actions/search.ts +146 -59
- package/src/store/chat/slices/builtinTool/selectors.test.ts +258 -0
- package/src/store/chat/slices/builtinTool/selectors.ts +25 -4
- package/src/store/chat/slices/message/action.test.ts +134 -16
- package/src/store/chat/slices/message/actions/internals.ts +33 -7
- package/src/store/chat/slices/message/actions/optimisticUpdate.ts +85 -52
- package/src/store/chat/slices/message/initialState.ts +0 -10
- package/src/store/chat/slices/message/selectors/messageState.ts +34 -12
- package/src/store/chat/slices/operation/__tests__/actions.test.ts +712 -16
- package/src/store/chat/slices/operation/__tests__/integration.test.ts +342 -0
- package/src/store/chat/slices/operation/__tests__/selectors.test.ts +257 -17
- package/src/store/chat/slices/operation/actions.ts +218 -11
- package/src/store/chat/slices/operation/selectors.ts +135 -6
- package/src/store/chat/slices/operation/types.ts +29 -3
- package/src/store/chat/slices/plugin/action.test.ts +30 -322
- package/src/store/chat/slices/plugin/actions/internals.ts +0 -14
- package/src/store/chat/slices/plugin/actions/optimisticUpdate.ts +21 -19
- package/src/store/chat/slices/plugin/actions/pluginTypes.ts +45 -27
- package/src/store/chat/slices/plugin/actions/publicApi.ts +3 -4
- package/src/store/chat/slices/plugin/actions/workflow.ts +0 -55
- package/src/store/chat/slices/thread/selectors/index.ts +4 -2
- package/src/store/chat/slices/topic/action.ts +3 -3
- package/src/store/chat/slices/translate/action.ts +54 -41
- package/src/tools/web-browsing/ExecutionRuntime/index.ts +5 -2
- package/src/tools/web-browsing/Portal/Search/Footer.tsx +11 -9
|
@@ -0,0 +1,1976 @@
|
|
|
1
|
+
import type { GeneralAgentCallToolResultPayload } from '@lobechat/agent-runtime';
|
|
2
|
+
import type { ChatToolPayload } from '@lobechat/types';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import type { Mock } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import type { OperationCancelContext } from '@/store/chat/slices/operation/types';
|
|
7
|
+
|
|
8
|
+
import { createAssistantMessage, createCallToolInstruction, createMockStore } from './fixtures';
|
|
9
|
+
import {
|
|
10
|
+
createInitialState,
|
|
11
|
+
createTestContext,
|
|
12
|
+
executeWithMockContext,
|
|
13
|
+
simulateOperationCancellation,
|
|
14
|
+
} from './helpers';
|
|
15
|
+
|
|
16
|
+
describe('call_tool executor', () => {
|
|
17
|
+
describe('Basic Behavior', () => {
|
|
18
|
+
it('should create tool message and execute tool successfully', async () => {
|
|
19
|
+
// Given
|
|
20
|
+
const mockStore = createMockStore();
|
|
21
|
+
const context = createTestContext({ sessionId: 'test-session', topicId: 'test-topic' });
|
|
22
|
+
|
|
23
|
+
const assistantMessage = createAssistantMessage({ groupId: 'group_123' });
|
|
24
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
25
|
+
|
|
26
|
+
const toolCall: ChatToolPayload = {
|
|
27
|
+
id: 'tool_call_1',
|
|
28
|
+
identifier: 'lobe-web-browsing',
|
|
29
|
+
apiName: 'search',
|
|
30
|
+
arguments: JSON.stringify({ query: 'test query' }),
|
|
31
|
+
type: 'default',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const instruction = createCallToolInstruction(toolCall, { parentMessageId: 'msg_parent' });
|
|
35
|
+
const state = createInitialState({ sessionId: 'test-session' });
|
|
36
|
+
|
|
37
|
+
// When
|
|
38
|
+
const result = await executeWithMockContext({
|
|
39
|
+
executor: 'call_tool',
|
|
40
|
+
instruction,
|
|
41
|
+
state,
|
|
42
|
+
mockStore,
|
|
43
|
+
context,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Then
|
|
47
|
+
expect(result.events).toHaveLength(1);
|
|
48
|
+
expect(result.events[0]).toMatchObject({
|
|
49
|
+
type: 'tool_result',
|
|
50
|
+
id: 'tool_call_1',
|
|
51
|
+
result: { error: null },
|
|
52
|
+
});
|
|
53
|
+
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(mockStore.internal_invokeDifferentTypePlugin).toHaveBeenCalledTimes(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should call internal_invokeDifferentTypePlugin with correct parameters', async () => {
|
|
58
|
+
// Given
|
|
59
|
+
const mockStore = createMockStore();
|
|
60
|
+
const context = createTestContext();
|
|
61
|
+
|
|
62
|
+
const assistantMessage = createAssistantMessage();
|
|
63
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
64
|
+
|
|
65
|
+
const toolCall: ChatToolPayload = {
|
|
66
|
+
id: 'tool_call_abc',
|
|
67
|
+
identifier: 'lobe-web-browsing',
|
|
68
|
+
apiName: 'craw',
|
|
69
|
+
arguments: JSON.stringify({ url: 'https://example.com' }),
|
|
70
|
+
type: 'default',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
74
|
+
const state = createInitialState();
|
|
75
|
+
|
|
76
|
+
// When
|
|
77
|
+
await executeWithMockContext({
|
|
78
|
+
executor: 'call_tool',
|
|
79
|
+
instruction,
|
|
80
|
+
state,
|
|
81
|
+
mockStore,
|
|
82
|
+
context,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Then
|
|
86
|
+
const createdMessage = await (mockStore.optimisticCreateMessage as Mock).mock.results[0]
|
|
87
|
+
.value;
|
|
88
|
+
expect(mockStore.internal_invokeDifferentTypePlugin).toHaveBeenCalledWith(
|
|
89
|
+
createdMessage.id,
|
|
90
|
+
toolCall,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should return correct result structure with tool_result event', async () => {
|
|
95
|
+
// Given
|
|
96
|
+
const mockStore = createMockStore({
|
|
97
|
+
internal_invokeDifferentTypePlugin: vi.fn().mockResolvedValue({
|
|
98
|
+
data: 'search results',
|
|
99
|
+
error: null,
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
const context = createTestContext();
|
|
103
|
+
|
|
104
|
+
const assistantMessage = createAssistantMessage();
|
|
105
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
106
|
+
|
|
107
|
+
const instruction = createCallToolInstruction();
|
|
108
|
+
const state = createInitialState();
|
|
109
|
+
|
|
110
|
+
// When
|
|
111
|
+
const result = await executeWithMockContext({
|
|
112
|
+
executor: 'call_tool',
|
|
113
|
+
instruction,
|
|
114
|
+
state,
|
|
115
|
+
mockStore,
|
|
116
|
+
context,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Then
|
|
120
|
+
expect(result.events).toHaveLength(1);
|
|
121
|
+
expect(result.events[0].type).toBe('tool_result');
|
|
122
|
+
const toolResultEvent = result.events[0] as any;
|
|
123
|
+
expect(toolResultEvent.result).toEqual({ data: 'search results', error: null });
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('Tool Message Creation', () => {
|
|
128
|
+
it('should create tool message with correct structure', async () => {
|
|
129
|
+
// Given
|
|
130
|
+
const mockStore = createMockStore();
|
|
131
|
+
const context = createTestContext({ sessionId: 'sess_123', topicId: 'topic_456' });
|
|
132
|
+
|
|
133
|
+
const assistantMessage = createAssistantMessage({ groupId: 'group_789' });
|
|
134
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
135
|
+
|
|
136
|
+
const toolCall: ChatToolPayload = {
|
|
137
|
+
id: 'tool_call_xyz',
|
|
138
|
+
identifier: 'lobe-web-browsing',
|
|
139
|
+
apiName: 'search',
|
|
140
|
+
arguments: JSON.stringify({ query: 'AI news' }),
|
|
141
|
+
type: 'default',
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const instruction = createCallToolInstruction(toolCall, {
|
|
145
|
+
parentMessageId: 'msg_parent_123',
|
|
146
|
+
});
|
|
147
|
+
const state = createInitialState();
|
|
148
|
+
|
|
149
|
+
// When
|
|
150
|
+
await executeWithMockContext({
|
|
151
|
+
executor: 'call_tool',
|
|
152
|
+
instruction,
|
|
153
|
+
state,
|
|
154
|
+
mockStore,
|
|
155
|
+
context: { ...context, sessionId: 'sess_123', topicId: 'topic_456' },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Then
|
|
159
|
+
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith({
|
|
160
|
+
content: '',
|
|
161
|
+
groupId: 'group_789',
|
|
162
|
+
parentId: 'msg_parent_123',
|
|
163
|
+
plugin: toolCall,
|
|
164
|
+
role: 'tool',
|
|
165
|
+
sessionId: 'sess_123',
|
|
166
|
+
threadId: undefined,
|
|
167
|
+
tool_call_id: 'tool_call_xyz',
|
|
168
|
+
topicId: 'topic_456',
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should use assistant message groupId for tool message', async () => {
|
|
173
|
+
// Given
|
|
174
|
+
const mockStore = createMockStore();
|
|
175
|
+
const context = createTestContext();
|
|
176
|
+
|
|
177
|
+
const assistantMessage = createAssistantMessage({ groupId: 'group_special' });
|
|
178
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
179
|
+
|
|
180
|
+
const instruction = createCallToolInstruction();
|
|
181
|
+
const state = createInitialState();
|
|
182
|
+
|
|
183
|
+
// When
|
|
184
|
+
await executeWithMockContext({
|
|
185
|
+
executor: 'call_tool',
|
|
186
|
+
instruction,
|
|
187
|
+
state,
|
|
188
|
+
mockStore,
|
|
189
|
+
context,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Then
|
|
193
|
+
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
|
|
194
|
+
expect.objectContaining({
|
|
195
|
+
groupId: 'group_special',
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should use correct parentId from payload', async () => {
|
|
201
|
+
// Given
|
|
202
|
+
const mockStore = createMockStore();
|
|
203
|
+
const context = createTestContext();
|
|
204
|
+
|
|
205
|
+
const assistantMessage = createAssistantMessage();
|
|
206
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
207
|
+
|
|
208
|
+
const instruction = createCallToolInstruction({}, { parentMessageId: 'msg_custom_parent' });
|
|
209
|
+
const state = createInitialState();
|
|
210
|
+
|
|
211
|
+
// When
|
|
212
|
+
await executeWithMockContext({
|
|
213
|
+
executor: 'call_tool',
|
|
214
|
+
instruction,
|
|
215
|
+
state,
|
|
216
|
+
mockStore,
|
|
217
|
+
context,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Then
|
|
221
|
+
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
|
|
222
|
+
expect.objectContaining({
|
|
223
|
+
parentId: 'msg_custom_parent',
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should preserve plugin payload details in tool message', async () => {
|
|
229
|
+
// Given
|
|
230
|
+
const mockStore = createMockStore();
|
|
231
|
+
const context = createTestContext();
|
|
232
|
+
|
|
233
|
+
const assistantMessage = createAssistantMessage();
|
|
234
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
235
|
+
|
|
236
|
+
const toolCall: ChatToolPayload = {
|
|
237
|
+
id: 'tool_complex',
|
|
238
|
+
identifier: 'custom-plugin',
|
|
239
|
+
apiName: 'complexApi',
|
|
240
|
+
arguments: JSON.stringify({
|
|
241
|
+
param1: 'value1',
|
|
242
|
+
param2: { nested: 'value2' },
|
|
243
|
+
param3: [1, 2, 3],
|
|
244
|
+
}),
|
|
245
|
+
type: 'builtin',
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
249
|
+
const state = createInitialState();
|
|
250
|
+
|
|
251
|
+
// When
|
|
252
|
+
await executeWithMockContext({
|
|
253
|
+
executor: 'call_tool',
|
|
254
|
+
instruction,
|
|
255
|
+
state,
|
|
256
|
+
mockStore,
|
|
257
|
+
context,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Then
|
|
261
|
+
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
|
|
262
|
+
expect.objectContaining({
|
|
263
|
+
plugin: toolCall,
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('Skip Create Tool Message Mode', () => {
|
|
270
|
+
it('should reuse existing tool message when skipCreateToolMessage is true', async () => {
|
|
271
|
+
// Given
|
|
272
|
+
const mockStore = createMockStore();
|
|
273
|
+
const context = createTestContext();
|
|
274
|
+
|
|
275
|
+
const assistantMessage = createAssistantMessage();
|
|
276
|
+
const existingToolMessage = {
|
|
277
|
+
id: 'msg_existing_tool',
|
|
278
|
+
role: 'tool',
|
|
279
|
+
content: '',
|
|
280
|
+
pluginIntervention: { status: 'pending' },
|
|
281
|
+
createdAt: Date.now(),
|
|
282
|
+
meta: {},
|
|
283
|
+
updatedAt: Date.now(),
|
|
284
|
+
};
|
|
285
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage, existingToolMessage as any];
|
|
286
|
+
|
|
287
|
+
const instruction = createCallToolInstruction(
|
|
288
|
+
{},
|
|
289
|
+
{
|
|
290
|
+
skipCreateToolMessage: true,
|
|
291
|
+
parentMessageId: 'msg_existing_tool',
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
const state = createInitialState();
|
|
295
|
+
|
|
296
|
+
// When
|
|
297
|
+
const result = await executeWithMockContext({
|
|
298
|
+
executor: 'call_tool',
|
|
299
|
+
instruction,
|
|
300
|
+
state,
|
|
301
|
+
mockStore,
|
|
302
|
+
context,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Then
|
|
306
|
+
expect(mockStore.optimisticCreateMessage).not.toHaveBeenCalled();
|
|
307
|
+
expect(mockStore.internal_invokeDifferentTypePlugin).toHaveBeenCalledWith(
|
|
308
|
+
'msg_existing_tool',
|
|
309
|
+
expect.any(Object),
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should still execute tool when skipping message creation', async () => {
|
|
314
|
+
// Given
|
|
315
|
+
const mockStore = createMockStore({
|
|
316
|
+
internal_invokeDifferentTypePlugin: vi.fn().mockResolvedValue({
|
|
317
|
+
data: 'result',
|
|
318
|
+
error: null,
|
|
319
|
+
}),
|
|
320
|
+
});
|
|
321
|
+
const context = createTestContext();
|
|
322
|
+
|
|
323
|
+
const assistantMessage = createAssistantMessage();
|
|
324
|
+
const existingToolMessage = {
|
|
325
|
+
id: 'msg_tool_reuse',
|
|
326
|
+
role: 'tool',
|
|
327
|
+
content: '',
|
|
328
|
+
createdAt: Date.now(),
|
|
329
|
+
meta: {},
|
|
330
|
+
updatedAt: Date.now(),
|
|
331
|
+
};
|
|
332
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage, existingToolMessage as any];
|
|
333
|
+
|
|
334
|
+
const toolCall: ChatToolPayload = {
|
|
335
|
+
id: 'tool_resume',
|
|
336
|
+
identifier: 'lobe-web-browsing',
|
|
337
|
+
apiName: 'search',
|
|
338
|
+
arguments: JSON.stringify({ query: 'resumed query' }),
|
|
339
|
+
type: 'default',
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const instruction = createCallToolInstruction(toolCall, {
|
|
343
|
+
skipCreateToolMessage: true,
|
|
344
|
+
parentMessageId: 'msg_tool_reuse',
|
|
345
|
+
});
|
|
346
|
+
const state = createInitialState();
|
|
347
|
+
|
|
348
|
+
// When
|
|
349
|
+
const result = await executeWithMockContext({
|
|
350
|
+
executor: 'call_tool',
|
|
351
|
+
instruction,
|
|
352
|
+
state,
|
|
353
|
+
mockStore,
|
|
354
|
+
context,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Then
|
|
358
|
+
expect(result.events).toHaveLength(1);
|
|
359
|
+
expect(result.events[0].type).toBe('tool_result');
|
|
360
|
+
expect(mockStore.internal_invokeDifferentTypePlugin).toHaveBeenCalledWith(
|
|
361
|
+
'msg_tool_reuse',
|
|
362
|
+
toolCall,
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe('Operation Tree Management', () => {
|
|
368
|
+
it('should create three-level operation tree', async () => {
|
|
369
|
+
// Given
|
|
370
|
+
const mockStore = createMockStore();
|
|
371
|
+
const context = createTestContext();
|
|
372
|
+
|
|
373
|
+
const assistantMessage = createAssistantMessage();
|
|
374
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
375
|
+
|
|
376
|
+
const instruction = createCallToolInstruction();
|
|
377
|
+
const state = createInitialState();
|
|
378
|
+
|
|
379
|
+
// When
|
|
380
|
+
await executeWithMockContext({
|
|
381
|
+
executor: 'call_tool',
|
|
382
|
+
instruction,
|
|
383
|
+
state,
|
|
384
|
+
mockStore,
|
|
385
|
+
context,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Then
|
|
389
|
+
expect(mockStore.startOperation).toHaveBeenCalledTimes(3);
|
|
390
|
+
|
|
391
|
+
// First: toolCalling operation
|
|
392
|
+
expect(mockStore.startOperation).toHaveBeenNthCalledWith(
|
|
393
|
+
1,
|
|
394
|
+
expect.objectContaining({
|
|
395
|
+
type: 'toolCalling',
|
|
396
|
+
parentOperationId: context.operationId,
|
|
397
|
+
}),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// Second: createToolMessage operation
|
|
401
|
+
expect(mockStore.startOperation).toHaveBeenNthCalledWith(
|
|
402
|
+
2,
|
|
403
|
+
expect.objectContaining({
|
|
404
|
+
type: 'createToolMessage',
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// Third: executeToolCall operation
|
|
409
|
+
expect(mockStore.startOperation).toHaveBeenNthCalledWith(
|
|
410
|
+
3,
|
|
411
|
+
expect.objectContaining({
|
|
412
|
+
type: 'executeToolCall',
|
|
413
|
+
}),
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should create toolCalling operation as parent', async () => {
|
|
418
|
+
// Given
|
|
419
|
+
const mockStore = createMockStore();
|
|
420
|
+
const context = createTestContext({ sessionId: 'sess_op', topicId: 'topic_op' });
|
|
421
|
+
|
|
422
|
+
const assistantMessage = createAssistantMessage();
|
|
423
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
424
|
+
|
|
425
|
+
const toolCall: ChatToolPayload = {
|
|
426
|
+
id: 'tool_op_test',
|
|
427
|
+
identifier: 'lobe-web-browsing',
|
|
428
|
+
apiName: 'search',
|
|
429
|
+
arguments: JSON.stringify({ query: 'test' }),
|
|
430
|
+
type: 'default',
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
434
|
+
const state = createInitialState();
|
|
435
|
+
|
|
436
|
+
// When
|
|
437
|
+
await executeWithMockContext({
|
|
438
|
+
executor: 'call_tool',
|
|
439
|
+
instruction,
|
|
440
|
+
state,
|
|
441
|
+
mockStore,
|
|
442
|
+
context: { ...context, sessionId: 'sess_op', topicId: 'topic_op' },
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Then
|
|
446
|
+
expect(mockStore.startOperation).toHaveBeenNthCalledWith(1, {
|
|
447
|
+
type: 'toolCalling',
|
|
448
|
+
context: {
|
|
449
|
+
sessionId: 'sess_op',
|
|
450
|
+
topicId: 'topic_op',
|
|
451
|
+
},
|
|
452
|
+
parentOperationId: context.operationId,
|
|
453
|
+
metadata: expect.objectContaining({
|
|
454
|
+
identifier: 'lobe-web-browsing',
|
|
455
|
+
apiName: 'search',
|
|
456
|
+
tool_call_id: 'tool_op_test',
|
|
457
|
+
}),
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('should create createToolMessage operation as child of toolCalling', async () => {
|
|
462
|
+
// Given
|
|
463
|
+
const mockStore = createMockStore();
|
|
464
|
+
const context = createTestContext({ sessionId: 'sess_child', topicId: 'topic_child' });
|
|
465
|
+
|
|
466
|
+
const assistantMessage = createAssistantMessage();
|
|
467
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
468
|
+
|
|
469
|
+
const toolCall: ChatToolPayload = {
|
|
470
|
+
id: 'tool_child_test',
|
|
471
|
+
identifier: 'lobe-web-browsing',
|
|
472
|
+
apiName: 'search',
|
|
473
|
+
arguments: JSON.stringify({ query: 'test' }),
|
|
474
|
+
type: 'default',
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
478
|
+
const state = createInitialState();
|
|
479
|
+
|
|
480
|
+
// When
|
|
481
|
+
await executeWithMockContext({
|
|
482
|
+
executor: 'call_tool',
|
|
483
|
+
instruction,
|
|
484
|
+
state,
|
|
485
|
+
mockStore,
|
|
486
|
+
context: { ...context, sessionId: 'sess_child', topicId: 'topic_child' },
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// Then
|
|
490
|
+
const toolCallingOpId = (mockStore.startOperation as Mock).mock.results[0].value.operationId;
|
|
491
|
+
|
|
492
|
+
expect(mockStore.startOperation).toHaveBeenNthCalledWith(2, {
|
|
493
|
+
type: 'createToolMessage',
|
|
494
|
+
context: {
|
|
495
|
+
sessionId: 'sess_child',
|
|
496
|
+
topicId: 'topic_child',
|
|
497
|
+
},
|
|
498
|
+
parentOperationId: toolCallingOpId,
|
|
499
|
+
metadata: expect.objectContaining({
|
|
500
|
+
tool_call_id: 'tool_child_test',
|
|
501
|
+
}),
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('should create executeToolCall operation with messageId in context', async () => {
|
|
506
|
+
// Given
|
|
507
|
+
const mockStore = createMockStore();
|
|
508
|
+
const context = createTestContext();
|
|
509
|
+
|
|
510
|
+
const assistantMessage = createAssistantMessage();
|
|
511
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
512
|
+
|
|
513
|
+
const instruction = createCallToolInstruction();
|
|
514
|
+
const state = createInitialState();
|
|
515
|
+
|
|
516
|
+
// When
|
|
517
|
+
await executeWithMockContext({
|
|
518
|
+
executor: 'call_tool',
|
|
519
|
+
instruction,
|
|
520
|
+
state,
|
|
521
|
+
mockStore,
|
|
522
|
+
context,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Then
|
|
526
|
+
const toolCallingOpId = (mockStore.startOperation as Mock).mock.results[0].value.operationId;
|
|
527
|
+
const createdMessage = await (mockStore.optimisticCreateMessage as Mock).mock.results[0]
|
|
528
|
+
.value;
|
|
529
|
+
|
|
530
|
+
expect(mockStore.startOperation).toHaveBeenNthCalledWith(
|
|
531
|
+
3,
|
|
532
|
+
expect.objectContaining({
|
|
533
|
+
type: 'executeToolCall',
|
|
534
|
+
context: {
|
|
535
|
+
messageId: createdMessage.id,
|
|
536
|
+
},
|
|
537
|
+
parentOperationId: toolCallingOpId,
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('should complete all operations on success', async () => {
|
|
543
|
+
// Given
|
|
544
|
+
const mockStore = createMockStore();
|
|
545
|
+
const context = createTestContext();
|
|
546
|
+
|
|
547
|
+
const assistantMessage = createAssistantMessage();
|
|
548
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
549
|
+
|
|
550
|
+
const instruction = createCallToolInstruction();
|
|
551
|
+
const state = createInitialState();
|
|
552
|
+
|
|
553
|
+
// When
|
|
554
|
+
await executeWithMockContext({
|
|
555
|
+
executor: 'call_tool',
|
|
556
|
+
instruction,
|
|
557
|
+
state,
|
|
558
|
+
mockStore,
|
|
559
|
+
context,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// Then - completeOperation called 3 times: createToolMessage, executeToolCall, and toolCalling
|
|
563
|
+
expect(mockStore.completeOperation).toHaveBeenCalledTimes(3);
|
|
564
|
+
|
|
565
|
+
const createToolMsgOpId = (mockStore.startOperation as Mock).mock.results[1].value
|
|
566
|
+
.operationId;
|
|
567
|
+
const executeToolOpId = (mockStore.startOperation as Mock).mock.results[2].value.operationId;
|
|
568
|
+
const toolCallingOpId = (mockStore.startOperation as Mock).mock.results[0].value.operationId;
|
|
569
|
+
|
|
570
|
+
expect(mockStore.completeOperation).toHaveBeenCalledWith(createToolMsgOpId);
|
|
571
|
+
expect(mockStore.completeOperation).toHaveBeenCalledWith(executeToolOpId);
|
|
572
|
+
expect(mockStore.completeOperation).toHaveBeenCalledWith(toolCallingOpId);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('should fail toolCalling operation on tool execution error', async () => {
|
|
576
|
+
// Given
|
|
577
|
+
const mockStore = createMockStore({
|
|
578
|
+
internal_invokeDifferentTypePlugin: vi.fn().mockResolvedValue({
|
|
579
|
+
error: 'Tool execution failed',
|
|
580
|
+
}),
|
|
581
|
+
});
|
|
582
|
+
const context = createTestContext();
|
|
583
|
+
|
|
584
|
+
const assistantMessage = createAssistantMessage();
|
|
585
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
586
|
+
|
|
587
|
+
const instruction = createCallToolInstruction();
|
|
588
|
+
const state = createInitialState();
|
|
589
|
+
|
|
590
|
+
// When
|
|
591
|
+
await executeWithMockContext({
|
|
592
|
+
executor: 'call_tool',
|
|
593
|
+
instruction,
|
|
594
|
+
state,
|
|
595
|
+
mockStore,
|
|
596
|
+
context,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// Then
|
|
600
|
+
const toolCallingOpId = (mockStore.startOperation as Mock).mock.results[0].value.operationId;
|
|
601
|
+
|
|
602
|
+
expect(mockStore.failOperation).toHaveBeenCalledWith(toolCallingOpId, {
|
|
603
|
+
type: 'ToolExecutionError',
|
|
604
|
+
message: 'Tool execution failed',
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
describe('CRITICAL: Parent Cancellation Check (Bug Fix Lines 395-406)', () => {
|
|
610
|
+
it('should skip tool execution if parent operation cancelled after message creation', async () => {
|
|
611
|
+
// Given
|
|
612
|
+
const mockStore = createMockStore();
|
|
613
|
+
const context = createTestContext();
|
|
614
|
+
|
|
615
|
+
const assistantMessage = createAssistantMessage();
|
|
616
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
617
|
+
|
|
618
|
+
const instruction = createCallToolInstruction();
|
|
619
|
+
const state = createInitialState();
|
|
620
|
+
|
|
621
|
+
// Mock optimisticCreateMessage to cancel parent operation before returning
|
|
622
|
+
mockStore.optimisticCreateMessage = vi.fn().mockImplementation(async (params) => {
|
|
623
|
+
const message = { id: 'msg_test', ...params };
|
|
624
|
+
|
|
625
|
+
// Cancel the toolCalling operation after message creation
|
|
626
|
+
const toolCallingOpId = (mockStore.startOperation as Mock).mock.results[0].value
|
|
627
|
+
.operationId;
|
|
628
|
+
const toolCallingOp = mockStore.operations[toolCallingOpId];
|
|
629
|
+
if (toolCallingOp) {
|
|
630
|
+
simulateOperationCancellation(toolCallingOp, 'Parent cancelled during message creation');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return message;
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// When
|
|
637
|
+
const result = await executeWithMockContext({
|
|
638
|
+
executor: 'call_tool',
|
|
639
|
+
instruction,
|
|
640
|
+
state,
|
|
641
|
+
mockStore,
|
|
642
|
+
context,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// Then - tool execution should be skipped
|
|
646
|
+
expect(mockStore.internal_invokeDifferentTypePlugin).not.toHaveBeenCalled();
|
|
647
|
+
expect(result.events).toHaveLength(0);
|
|
648
|
+
expect(result.newState).toEqual(state);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('should check parent abortController signal after message creation', async () => {
|
|
652
|
+
// Given
|
|
653
|
+
const mockStore = createMockStore();
|
|
654
|
+
const context = createTestContext();
|
|
655
|
+
|
|
656
|
+
const assistantMessage = createAssistantMessage();
|
|
657
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
658
|
+
|
|
659
|
+
const instruction = createCallToolInstruction();
|
|
660
|
+
const state = createInitialState();
|
|
661
|
+
|
|
662
|
+
// Setup to abort parent operation during message creation
|
|
663
|
+
mockStore.optimisticCreateMessage = vi.fn().mockImplementation(async (params) => {
|
|
664
|
+
const message = { id: 'msg_abort_test', ...params };
|
|
665
|
+
|
|
666
|
+
// Abort the parent toolCalling operation
|
|
667
|
+
const toolCallingOpId = (mockStore.startOperation as Mock).mock.results[0].value
|
|
668
|
+
.operationId;
|
|
669
|
+
mockStore.operations[toolCallingOpId].abortController.abort();
|
|
670
|
+
|
|
671
|
+
return message;
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// When
|
|
675
|
+
const result = await executeWithMockContext({
|
|
676
|
+
executor: 'call_tool',
|
|
677
|
+
instruction,
|
|
678
|
+
state,
|
|
679
|
+
mockStore,
|
|
680
|
+
context,
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// Then
|
|
684
|
+
const toolCallingOpId = (mockStore.startOperation as Mock).mock.results[0].value.operationId;
|
|
685
|
+
const operation = mockStore.operations[toolCallingOpId];
|
|
686
|
+
|
|
687
|
+
expect(operation.abortController.signal.aborted).toBe(true);
|
|
688
|
+
expect(mockStore.internal_invokeDifferentTypePlugin).not.toHaveBeenCalled();
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it('should return early without executing tool when parent cancelled', async () => {
|
|
692
|
+
// Given
|
|
693
|
+
const mockStore = createMockStore();
|
|
694
|
+
const context = createTestContext();
|
|
695
|
+
|
|
696
|
+
const assistantMessage = createAssistantMessage();
|
|
697
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
698
|
+
|
|
699
|
+
const instruction = createCallToolInstruction();
|
|
700
|
+
const state = createInitialState({ sessionId: 'test-session', stepCount: 5 });
|
|
701
|
+
|
|
702
|
+
// Cancel parent during message creation
|
|
703
|
+
mockStore.optimisticCreateMessage = vi.fn().mockImplementation(async (params) => {
|
|
704
|
+
const toolCallingOpId = (mockStore.startOperation as Mock).mock.results[0].value
|
|
705
|
+
.operationId;
|
|
706
|
+
simulateOperationCancellation(mockStore.operations[toolCallingOpId]);
|
|
707
|
+
return { id: 'msg_early_return', ...params };
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// When
|
|
711
|
+
const result = await executeWithMockContext({
|
|
712
|
+
executor: 'call_tool',
|
|
713
|
+
instruction,
|
|
714
|
+
state,
|
|
715
|
+
mockStore,
|
|
716
|
+
context,
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// Then
|
|
720
|
+
expect(result.events).toHaveLength(0);
|
|
721
|
+
expect(result.newState).toEqual(state);
|
|
722
|
+
expect(result.newState.stepCount).toBe(5); // Unchanged
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('should not create executeToolCall operation if parent cancelled', async () => {
|
|
726
|
+
// Given
|
|
727
|
+
const mockStore = createMockStore();
|
|
728
|
+
const context = createTestContext();
|
|
729
|
+
|
|
730
|
+
const assistantMessage = createAssistantMessage();
|
|
731
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
732
|
+
|
|
733
|
+
const instruction = createCallToolInstruction();
|
|
734
|
+
const state = createInitialState();
|
|
735
|
+
|
|
736
|
+
// Cancel during message creation
|
|
737
|
+
mockStore.optimisticCreateMessage = vi.fn().mockImplementation(async (params) => {
|
|
738
|
+
const toolCallingOpId = (mockStore.startOperation as Mock).mock.results[0].value
|
|
739
|
+
.operationId;
|
|
740
|
+
mockStore.operations[toolCallingOpId].abortController.abort();
|
|
741
|
+
return { id: 'msg_no_execute', ...params };
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
// When
|
|
745
|
+
await executeWithMockContext({
|
|
746
|
+
executor: 'call_tool',
|
|
747
|
+
instruction,
|
|
748
|
+
state,
|
|
749
|
+
mockStore,
|
|
750
|
+
context,
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// Then - only 2 operations created (toolCalling + createToolMessage), NOT executeToolCall
|
|
754
|
+
expect(mockStore.startOperation).toHaveBeenCalledTimes(2);
|
|
755
|
+
expect(mockStore.startOperation).not.toHaveBeenCalledWith(
|
|
756
|
+
expect.objectContaining({ type: 'executeToolCall' }),
|
|
757
|
+
);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it('should proceed normally if parent not cancelled', async () => {
|
|
761
|
+
// Given
|
|
762
|
+
const mockStore = createMockStore();
|
|
763
|
+
const context = createTestContext();
|
|
764
|
+
|
|
765
|
+
const assistantMessage = createAssistantMessage();
|
|
766
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
767
|
+
|
|
768
|
+
const instruction = createCallToolInstruction();
|
|
769
|
+
const state = createInitialState();
|
|
770
|
+
|
|
771
|
+
// When
|
|
772
|
+
const result = await executeWithMockContext({
|
|
773
|
+
executor: 'call_tool',
|
|
774
|
+
instruction,
|
|
775
|
+
state,
|
|
776
|
+
mockStore,
|
|
777
|
+
context,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// Then - normal execution
|
|
781
|
+
expect(mockStore.startOperation).toHaveBeenCalledTimes(3);
|
|
782
|
+
expect(mockStore.internal_invokeDifferentTypePlugin).toHaveBeenCalledTimes(1);
|
|
783
|
+
expect(result.events).toHaveLength(1);
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
describe('Cancel Handler Behavior', () => {
|
|
788
|
+
it('should register cancel handler for createToolMessage operation', async () => {
|
|
789
|
+
// Given
|
|
790
|
+
const mockStore = createMockStore();
|
|
791
|
+
const context = createTestContext();
|
|
792
|
+
|
|
793
|
+
const assistantMessage = createAssistantMessage();
|
|
794
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
795
|
+
|
|
796
|
+
const instruction = createCallToolInstruction();
|
|
797
|
+
const state = createInitialState();
|
|
798
|
+
|
|
799
|
+
// When
|
|
800
|
+
await executeWithMockContext({
|
|
801
|
+
executor: 'call_tool',
|
|
802
|
+
instruction,
|
|
803
|
+
state,
|
|
804
|
+
mockStore,
|
|
805
|
+
context,
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// Then
|
|
809
|
+
expect(mockStore.onOperationCancel).toHaveBeenCalledTimes(2);
|
|
810
|
+
|
|
811
|
+
const createToolMsgOpId = (mockStore.startOperation as Mock).mock.results[1].value
|
|
812
|
+
.operationId;
|
|
813
|
+
expect(mockStore.onOperationCancel).toHaveBeenCalledWith(
|
|
814
|
+
createToolMsgOpId,
|
|
815
|
+
expect.any(Function),
|
|
816
|
+
);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it('should register cancel handler for executeToolCall operation', async () => {
|
|
820
|
+
// Given
|
|
821
|
+
const mockStore = createMockStore();
|
|
822
|
+
const context = createTestContext();
|
|
823
|
+
|
|
824
|
+
const assistantMessage = createAssistantMessage();
|
|
825
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
826
|
+
|
|
827
|
+
const instruction = createCallToolInstruction();
|
|
828
|
+
const state = createInitialState();
|
|
829
|
+
|
|
830
|
+
// When
|
|
831
|
+
await executeWithMockContext({
|
|
832
|
+
executor: 'call_tool',
|
|
833
|
+
instruction,
|
|
834
|
+
state,
|
|
835
|
+
mockStore,
|
|
836
|
+
context,
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
// Then
|
|
840
|
+
const executeToolOpId = (mockStore.startOperation as Mock).mock.results[2].value.operationId;
|
|
841
|
+
expect(mockStore.onOperationCancel).toHaveBeenCalledWith(
|
|
842
|
+
executeToolOpId,
|
|
843
|
+
expect.any(Function),
|
|
844
|
+
);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('should update operation metadata with createMessagePromise', async () => {
|
|
848
|
+
// Given
|
|
849
|
+
const mockStore = createMockStore();
|
|
850
|
+
const context = createTestContext();
|
|
851
|
+
|
|
852
|
+
const assistantMessage = createAssistantMessage();
|
|
853
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
854
|
+
|
|
855
|
+
const instruction = createCallToolInstruction();
|
|
856
|
+
const state = createInitialState();
|
|
857
|
+
|
|
858
|
+
// When
|
|
859
|
+
await executeWithMockContext({
|
|
860
|
+
executor: 'call_tool',
|
|
861
|
+
instruction,
|
|
862
|
+
state,
|
|
863
|
+
mockStore,
|
|
864
|
+
context,
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// Then
|
|
868
|
+
const createToolMsgOpId = (mockStore.startOperation as Mock).mock.results[1].value
|
|
869
|
+
.operationId;
|
|
870
|
+
expect(mockStore.updateOperationMetadata).toHaveBeenCalledWith(
|
|
871
|
+
createToolMsgOpId,
|
|
872
|
+
expect.objectContaining({
|
|
873
|
+
createMessagePromise: expect.any(Promise),
|
|
874
|
+
}),
|
|
875
|
+
);
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
describe('Usage Tracking', () => {
|
|
880
|
+
it('should accumulate tool usage with execution time', async () => {
|
|
881
|
+
// Given
|
|
882
|
+
const mockStore = createMockStore();
|
|
883
|
+
const context = createTestContext();
|
|
884
|
+
|
|
885
|
+
const assistantMessage = createAssistantMessage();
|
|
886
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
887
|
+
|
|
888
|
+
const toolCall: ChatToolPayload = {
|
|
889
|
+
id: 'tool_usage',
|
|
890
|
+
identifier: 'lobe-web-browsing',
|
|
891
|
+
apiName: 'search',
|
|
892
|
+
arguments: JSON.stringify({ query: 'test' }),
|
|
893
|
+
type: 'default',
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
897
|
+
const state = createInitialState();
|
|
898
|
+
|
|
899
|
+
// When
|
|
900
|
+
const result = await executeWithMockContext({
|
|
901
|
+
executor: 'call_tool',
|
|
902
|
+
instruction,
|
|
903
|
+
state,
|
|
904
|
+
mockStore,
|
|
905
|
+
context,
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
// Then
|
|
909
|
+
expect(result.newState.usage.tools.totalCalls).toBe(1);
|
|
910
|
+
expect(result.newState.usage.tools.totalTimeMs).toBeGreaterThanOrEqual(0);
|
|
911
|
+
expect(result.newState.usage.tools.byTool).toHaveLength(1);
|
|
912
|
+
expect(result.newState.usage.tools.byTool[0]).toMatchObject({
|
|
913
|
+
name: 'lobe-web-browsing/search',
|
|
914
|
+
calls: 1,
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it('should use TOOL_PRICING for search tool', async () => {
|
|
919
|
+
// Given
|
|
920
|
+
const mockStore = createMockStore();
|
|
921
|
+
const context = createTestContext();
|
|
922
|
+
|
|
923
|
+
const assistantMessage = createAssistantMessage();
|
|
924
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
925
|
+
|
|
926
|
+
const toolCall: ChatToolPayload = {
|
|
927
|
+
id: 'tool_pricing_search',
|
|
928
|
+
identifier: 'lobe-web-browsing',
|
|
929
|
+
apiName: 'search',
|
|
930
|
+
arguments: JSON.stringify({ query: 'test' }),
|
|
931
|
+
type: 'default',
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
935
|
+
const state = createInitialState();
|
|
936
|
+
|
|
937
|
+
// When
|
|
938
|
+
const result = await executeWithMockContext({
|
|
939
|
+
executor: 'call_tool',
|
|
940
|
+
instruction,
|
|
941
|
+
state,
|
|
942
|
+
mockStore,
|
|
943
|
+
context,
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// Then - TOOL_PRICING['lobe-web-browsing/search'] = 0.001
|
|
947
|
+
expect(result.newState.cost?.total).toBeCloseTo(0.001, 5);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it('should use TOOL_PRICING for craw tool', async () => {
|
|
951
|
+
// Given
|
|
952
|
+
const mockStore = createMockStore();
|
|
953
|
+
const context = createTestContext();
|
|
954
|
+
|
|
955
|
+
const assistantMessage = createAssistantMessage();
|
|
956
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
957
|
+
|
|
958
|
+
const toolCall: ChatToolPayload = {
|
|
959
|
+
id: 'tool_pricing_craw',
|
|
960
|
+
identifier: 'lobe-web-browsing',
|
|
961
|
+
apiName: 'craw',
|
|
962
|
+
arguments: JSON.stringify({ url: 'https://example.com' }),
|
|
963
|
+
type: 'default',
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
967
|
+
const state = createInitialState();
|
|
968
|
+
|
|
969
|
+
// When
|
|
970
|
+
const result = await executeWithMockContext({
|
|
971
|
+
executor: 'call_tool',
|
|
972
|
+
instruction,
|
|
973
|
+
state,
|
|
974
|
+
mockStore,
|
|
975
|
+
context,
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
// Then - TOOL_PRICING['lobe-web-browsing/craw'] = 0.002
|
|
979
|
+
expect(result.newState.cost?.total).toBeCloseTo(0.002, 5);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it('should use zero cost for unknown tools', async () => {
|
|
983
|
+
// Given
|
|
984
|
+
const mockStore = createMockStore();
|
|
985
|
+
const context = createTestContext();
|
|
986
|
+
|
|
987
|
+
const assistantMessage = createAssistantMessage();
|
|
988
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
989
|
+
|
|
990
|
+
const toolCall: ChatToolPayload = {
|
|
991
|
+
id: 'tool_unknown',
|
|
992
|
+
identifier: 'custom-plugin',
|
|
993
|
+
apiName: 'unknownApi',
|
|
994
|
+
arguments: JSON.stringify({ param: 'value' }),
|
|
995
|
+
type: 'default',
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
999
|
+
const state = createInitialState();
|
|
1000
|
+
|
|
1001
|
+
// When
|
|
1002
|
+
const result = await executeWithMockContext({
|
|
1003
|
+
executor: 'call_tool',
|
|
1004
|
+
instruction,
|
|
1005
|
+
state,
|
|
1006
|
+
mockStore,
|
|
1007
|
+
context,
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// Then
|
|
1011
|
+
if (result.newState.cost) {
|
|
1012
|
+
expect(result.newState.cost.total).toBe(0);
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it('should track successful tool execution in usage', async () => {
|
|
1017
|
+
// Given
|
|
1018
|
+
const mockStore = createMockStore({
|
|
1019
|
+
internal_invokeDifferentTypePlugin: vi.fn().mockResolvedValue({ error: null }),
|
|
1020
|
+
});
|
|
1021
|
+
const context = createTestContext();
|
|
1022
|
+
|
|
1023
|
+
const assistantMessage = createAssistantMessage();
|
|
1024
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1025
|
+
|
|
1026
|
+
const instruction = createCallToolInstruction();
|
|
1027
|
+
const state = createInitialState();
|
|
1028
|
+
|
|
1029
|
+
// When
|
|
1030
|
+
const result = await executeWithMockContext({
|
|
1031
|
+
executor: 'call_tool',
|
|
1032
|
+
instruction,
|
|
1033
|
+
state,
|
|
1034
|
+
mockStore,
|
|
1035
|
+
context,
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
// Then
|
|
1039
|
+
const toolUsage = result.newState.usage.tools.byTool.find(
|
|
1040
|
+
(t) => t.name === 'lobe-web-browsing/search',
|
|
1041
|
+
);
|
|
1042
|
+
expect(toolUsage).toBeDefined();
|
|
1043
|
+
expect(toolUsage?.calls).toBe(1);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it('should track failed tool execution in usage', async () => {
|
|
1047
|
+
// Given
|
|
1048
|
+
const mockStore = createMockStore({
|
|
1049
|
+
internal_invokeDifferentTypePlugin: vi.fn().mockResolvedValue({ error: 'Failed' }),
|
|
1050
|
+
});
|
|
1051
|
+
const context = createTestContext();
|
|
1052
|
+
|
|
1053
|
+
const assistantMessage = createAssistantMessage();
|
|
1054
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1055
|
+
|
|
1056
|
+
const instruction = createCallToolInstruction();
|
|
1057
|
+
const state = createInitialState();
|
|
1058
|
+
|
|
1059
|
+
// When
|
|
1060
|
+
const result = await executeWithMockContext({
|
|
1061
|
+
executor: 'call_tool',
|
|
1062
|
+
instruction,
|
|
1063
|
+
state,
|
|
1064
|
+
mockStore,
|
|
1065
|
+
context,
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
// Then
|
|
1069
|
+
const toolUsage = result.newState.usage.tools.byTool.find(
|
|
1070
|
+
(t) => t.name === 'lobe-web-browsing/search',
|
|
1071
|
+
);
|
|
1072
|
+
expect(toolUsage).toBeDefined();
|
|
1073
|
+
expect(toolUsage?.calls).toBe(1);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it('should include stepUsage in nextContext', async () => {
|
|
1077
|
+
// Given
|
|
1078
|
+
const mockStore = createMockStore();
|
|
1079
|
+
const context = createTestContext();
|
|
1080
|
+
|
|
1081
|
+
const assistantMessage = createAssistantMessage();
|
|
1082
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1083
|
+
|
|
1084
|
+
const toolCall: ChatToolPayload = {
|
|
1085
|
+
id: 'tool_step_usage',
|
|
1086
|
+
identifier: 'lobe-web-browsing',
|
|
1087
|
+
apiName: 'search',
|
|
1088
|
+
arguments: JSON.stringify({ query: 'test' }),
|
|
1089
|
+
type: 'default',
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
1093
|
+
const state = createInitialState();
|
|
1094
|
+
|
|
1095
|
+
// When
|
|
1096
|
+
const result = await executeWithMockContext({
|
|
1097
|
+
executor: 'call_tool',
|
|
1098
|
+
instruction,
|
|
1099
|
+
state,
|
|
1100
|
+
mockStore,
|
|
1101
|
+
context,
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
// Then
|
|
1105
|
+
expect(result.nextContext?.stepUsage).toEqual({
|
|
1106
|
+
cost: 0.001,
|
|
1107
|
+
toolName: 'lobe-web-browsing/search',
|
|
1108
|
+
unitPrice: 0.001,
|
|
1109
|
+
usageCount: 1,
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
describe('Error Handling', () => {
|
|
1115
|
+
it('should handle message creation failure', async () => {
|
|
1116
|
+
// Given
|
|
1117
|
+
const mockStore = createMockStore({
|
|
1118
|
+
optimisticCreateMessage: vi.fn().mockResolvedValue(null),
|
|
1119
|
+
});
|
|
1120
|
+
const context = createTestContext();
|
|
1121
|
+
|
|
1122
|
+
const assistantMessage = createAssistantMessage();
|
|
1123
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1124
|
+
|
|
1125
|
+
const instruction = createCallToolInstruction();
|
|
1126
|
+
const state = createInitialState();
|
|
1127
|
+
|
|
1128
|
+
// When
|
|
1129
|
+
const result = await executeWithMockContext({
|
|
1130
|
+
executor: 'call_tool',
|
|
1131
|
+
instruction,
|
|
1132
|
+
state,
|
|
1133
|
+
mockStore,
|
|
1134
|
+
context,
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
// Then
|
|
1138
|
+
expect(result.events).toHaveLength(1);
|
|
1139
|
+
expect(result.events[0].type).toBe('error');
|
|
1140
|
+
expect(result.newState).toEqual(state);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it('should fail createToolMessage operation on message creation error', async () => {
|
|
1144
|
+
// Given
|
|
1145
|
+
const mockStore = createMockStore({
|
|
1146
|
+
optimisticCreateMessage: vi.fn().mockResolvedValue(null),
|
|
1147
|
+
});
|
|
1148
|
+
const context = createTestContext();
|
|
1149
|
+
|
|
1150
|
+
const assistantMessage = createAssistantMessage();
|
|
1151
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1152
|
+
|
|
1153
|
+
const toolCall: ChatToolPayload = {
|
|
1154
|
+
id: 'tool_fail_create',
|
|
1155
|
+
identifier: 'lobe-web-browsing',
|
|
1156
|
+
apiName: 'search',
|
|
1157
|
+
arguments: JSON.stringify({ query: 'test' }),
|
|
1158
|
+
type: 'default',
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
1162
|
+
const state = createInitialState();
|
|
1163
|
+
|
|
1164
|
+
// When
|
|
1165
|
+
await executeWithMockContext({
|
|
1166
|
+
executor: 'call_tool',
|
|
1167
|
+
instruction,
|
|
1168
|
+
state,
|
|
1169
|
+
mockStore,
|
|
1170
|
+
context,
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
// Then
|
|
1174
|
+
const createToolMsgOpId = (mockStore.startOperation as Mock).mock.results[1].value
|
|
1175
|
+
.operationId;
|
|
1176
|
+
expect(mockStore.failOperation).toHaveBeenCalledWith(createToolMsgOpId, {
|
|
1177
|
+
type: 'CreateMessageError',
|
|
1178
|
+
message: expect.stringContaining('Failed to create tool message'),
|
|
1179
|
+
});
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
it('should return error event on tool execution error', async () => {
|
|
1183
|
+
// Given
|
|
1184
|
+
const mockStore = createMockStore({
|
|
1185
|
+
internal_invokeDifferentTypePlugin: vi.fn().mockResolvedValue({
|
|
1186
|
+
error: 'Network timeout',
|
|
1187
|
+
}),
|
|
1188
|
+
});
|
|
1189
|
+
const context = createTestContext();
|
|
1190
|
+
|
|
1191
|
+
const assistantMessage = createAssistantMessage();
|
|
1192
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1193
|
+
|
|
1194
|
+
const instruction = createCallToolInstruction();
|
|
1195
|
+
const state = createInitialState();
|
|
1196
|
+
|
|
1197
|
+
// When
|
|
1198
|
+
const result = await executeWithMockContext({
|
|
1199
|
+
executor: 'call_tool',
|
|
1200
|
+
instruction,
|
|
1201
|
+
state,
|
|
1202
|
+
mockStore,
|
|
1203
|
+
context,
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
// Then
|
|
1207
|
+
expect(result.events[0]).toMatchObject({
|
|
1208
|
+
type: 'tool_result',
|
|
1209
|
+
result: { error: 'Network timeout' },
|
|
1210
|
+
});
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it('should handle exception during execution', async () => {
|
|
1214
|
+
// Given
|
|
1215
|
+
const mockStore = createMockStore({
|
|
1216
|
+
optimisticCreateMessage: vi.fn().mockRejectedValue(new Error('Database error')),
|
|
1217
|
+
});
|
|
1218
|
+
const context = createTestContext();
|
|
1219
|
+
|
|
1220
|
+
const assistantMessage = createAssistantMessage();
|
|
1221
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1222
|
+
|
|
1223
|
+
const instruction = createCallToolInstruction();
|
|
1224
|
+
const state = createInitialState();
|
|
1225
|
+
|
|
1226
|
+
// When
|
|
1227
|
+
const result = await executeWithMockContext({
|
|
1228
|
+
executor: 'call_tool',
|
|
1229
|
+
instruction,
|
|
1230
|
+
state,
|
|
1231
|
+
mockStore,
|
|
1232
|
+
context,
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
// Then
|
|
1236
|
+
expect(result.events).toHaveLength(1);
|
|
1237
|
+
expect(result.events[0].type).toBe('error');
|
|
1238
|
+
expect(result.newState).toEqual(state);
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
it('should return original state on error', async () => {
|
|
1242
|
+
// Given
|
|
1243
|
+
const mockStore = createMockStore({
|
|
1244
|
+
internal_invokeDifferentTypePlugin: vi.fn().mockRejectedValue(new Error('Tool crashed')),
|
|
1245
|
+
});
|
|
1246
|
+
const context = createTestContext();
|
|
1247
|
+
|
|
1248
|
+
const assistantMessage = createAssistantMessage();
|
|
1249
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1250
|
+
|
|
1251
|
+
const instruction = createCallToolInstruction();
|
|
1252
|
+
const state = createInitialState({ sessionId: 'test-session', stepCount: 10 });
|
|
1253
|
+
|
|
1254
|
+
// When
|
|
1255
|
+
const result = await executeWithMockContext({
|
|
1256
|
+
executor: 'call_tool',
|
|
1257
|
+
instruction,
|
|
1258
|
+
state,
|
|
1259
|
+
mockStore,
|
|
1260
|
+
context,
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
// Then
|
|
1264
|
+
expect(result.newState).toEqual(state);
|
|
1265
|
+
expect(result.events[0].type).toBe('error');
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
describe('State Management', () => {
|
|
1270
|
+
it('should update messages from dbMessagesMap', async () => {
|
|
1271
|
+
// Given
|
|
1272
|
+
const mockStore = createMockStore();
|
|
1273
|
+
const context = createTestContext();
|
|
1274
|
+
|
|
1275
|
+
const assistantMessage = createAssistantMessage();
|
|
1276
|
+
const updatedMessages = [
|
|
1277
|
+
assistantMessage,
|
|
1278
|
+
{
|
|
1279
|
+
id: 'msg_tool_updated',
|
|
1280
|
+
role: 'tool',
|
|
1281
|
+
content: '',
|
|
1282
|
+
createdAt: Date.now(),
|
|
1283
|
+
meta: {},
|
|
1284
|
+
updatedAt: Date.now(),
|
|
1285
|
+
} as any,
|
|
1286
|
+
];
|
|
1287
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1288
|
+
|
|
1289
|
+
const instruction = createCallToolInstruction();
|
|
1290
|
+
const state = createInitialState();
|
|
1291
|
+
|
|
1292
|
+
// Mock internal_invokeDifferentTypePlugin to update dbMessagesMap
|
|
1293
|
+
mockStore.internal_invokeDifferentTypePlugin = vi.fn().mockImplementation(async () => {
|
|
1294
|
+
mockStore.dbMessagesMap[context.messageKey] = updatedMessages;
|
|
1295
|
+
return { error: null };
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
// When
|
|
1299
|
+
const result = await executeWithMockContext({
|
|
1300
|
+
executor: 'call_tool',
|
|
1301
|
+
instruction,
|
|
1302
|
+
state,
|
|
1303
|
+
mockStore,
|
|
1304
|
+
context,
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
// Then
|
|
1308
|
+
expect(result.newState.messages).toEqual(updatedMessages);
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
it('should preserve other state fields', async () => {
|
|
1312
|
+
// Given
|
|
1313
|
+
const mockStore = createMockStore();
|
|
1314
|
+
const context = createTestContext();
|
|
1315
|
+
|
|
1316
|
+
const assistantMessage = createAssistantMessage();
|
|
1317
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1318
|
+
|
|
1319
|
+
const instruction = createCallToolInstruction();
|
|
1320
|
+
const state = createInitialState({
|
|
1321
|
+
sessionId: 'preserve-session',
|
|
1322
|
+
stepCount: 15,
|
|
1323
|
+
status: 'running',
|
|
1324
|
+
lastModified: '2024-01-01T00:00:00.000Z',
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
// When
|
|
1328
|
+
const result = await executeWithMockContext({
|
|
1329
|
+
executor: 'call_tool',
|
|
1330
|
+
instruction,
|
|
1331
|
+
state,
|
|
1332
|
+
mockStore,
|
|
1333
|
+
context,
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
// Then
|
|
1337
|
+
expect(result.newState.sessionId).toBe('preserve-session');
|
|
1338
|
+
expect(result.newState.stepCount).toBe(15);
|
|
1339
|
+
expect(result.newState.status).toBe('running');
|
|
1340
|
+
expect(result.newState.lastModified).toBe('2024-01-01T00:00:00.000Z');
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
it('should not mutate original state', async () => {
|
|
1344
|
+
// Given
|
|
1345
|
+
const mockStore = createMockStore();
|
|
1346
|
+
const context = createTestContext();
|
|
1347
|
+
|
|
1348
|
+
const assistantMessage = createAssistantMessage();
|
|
1349
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1350
|
+
|
|
1351
|
+
const instruction = createCallToolInstruction();
|
|
1352
|
+
const state = createInitialState({ sessionId: 'immutable-test', stepCount: 5 });
|
|
1353
|
+
const originalState = JSON.parse(JSON.stringify(state));
|
|
1354
|
+
|
|
1355
|
+
// When
|
|
1356
|
+
await executeWithMockContext({
|
|
1357
|
+
executor: 'call_tool',
|
|
1358
|
+
instruction,
|
|
1359
|
+
state,
|
|
1360
|
+
mockStore,
|
|
1361
|
+
context,
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
// Then
|
|
1365
|
+
expect(state).toEqual(originalState);
|
|
1366
|
+
});
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
describe('Next Context', () => {
|
|
1370
|
+
it('should set phase to tool_result', async () => {
|
|
1371
|
+
// Given
|
|
1372
|
+
const mockStore = createMockStore();
|
|
1373
|
+
const context = createTestContext();
|
|
1374
|
+
|
|
1375
|
+
const assistantMessage = createAssistantMessage();
|
|
1376
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1377
|
+
|
|
1378
|
+
const instruction = createCallToolInstruction();
|
|
1379
|
+
const state = createInitialState();
|
|
1380
|
+
|
|
1381
|
+
// When
|
|
1382
|
+
const result = await executeWithMockContext({
|
|
1383
|
+
executor: 'call_tool',
|
|
1384
|
+
instruction,
|
|
1385
|
+
state,
|
|
1386
|
+
mockStore,
|
|
1387
|
+
context,
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
// Then
|
|
1391
|
+
expect(result.nextContext?.phase).toBe('tool_result');
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it('should include correct payload with tool data', async () => {
|
|
1395
|
+
// Given
|
|
1396
|
+
const mockStore = createMockStore({
|
|
1397
|
+
internal_invokeDifferentTypePlugin: vi.fn().mockResolvedValue({
|
|
1398
|
+
data: 'search results',
|
|
1399
|
+
error: null,
|
|
1400
|
+
}),
|
|
1401
|
+
});
|
|
1402
|
+
const context = createTestContext();
|
|
1403
|
+
|
|
1404
|
+
const assistantMessage = createAssistantMessage();
|
|
1405
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1406
|
+
|
|
1407
|
+
const toolCall: ChatToolPayload = {
|
|
1408
|
+
id: 'tool_context_test',
|
|
1409
|
+
identifier: 'lobe-web-browsing',
|
|
1410
|
+
apiName: 'search',
|
|
1411
|
+
arguments: JSON.stringify({ query: 'AI news' }),
|
|
1412
|
+
type: 'default',
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
1416
|
+
const state = createInitialState();
|
|
1417
|
+
|
|
1418
|
+
// When
|
|
1419
|
+
const result = await executeWithMockContext({
|
|
1420
|
+
executor: 'call_tool',
|
|
1421
|
+
instruction,
|
|
1422
|
+
state,
|
|
1423
|
+
mockStore,
|
|
1424
|
+
context,
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
// Then
|
|
1428
|
+
const createdMessage = await (mockStore.optimisticCreateMessage as Mock).mock.results[0]
|
|
1429
|
+
.value;
|
|
1430
|
+
const payload = result.nextContext?.payload as GeneralAgentCallToolResultPayload;
|
|
1431
|
+
expect(payload).toMatchObject({
|
|
1432
|
+
data: { data: 'search results', error: null },
|
|
1433
|
+
isSuccess: true,
|
|
1434
|
+
toolCall: toolCall,
|
|
1435
|
+
toolCallId: 'tool_context_test',
|
|
1436
|
+
parentMessageId: createdMessage.id,
|
|
1437
|
+
executionTime: expect.any(Number),
|
|
1438
|
+
});
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
it('should increment stepCount in next context', async () => {
|
|
1442
|
+
// Given
|
|
1443
|
+
const mockStore = createMockStore();
|
|
1444
|
+
const context = createTestContext();
|
|
1445
|
+
|
|
1446
|
+
const assistantMessage = createAssistantMessage();
|
|
1447
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1448
|
+
|
|
1449
|
+
const instruction = createCallToolInstruction();
|
|
1450
|
+
const state = createInitialState({ stepCount: 7 });
|
|
1451
|
+
|
|
1452
|
+
// When
|
|
1453
|
+
const result = await executeWithMockContext({
|
|
1454
|
+
executor: 'call_tool',
|
|
1455
|
+
instruction,
|
|
1456
|
+
state,
|
|
1457
|
+
mockStore,
|
|
1458
|
+
context,
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
// Then
|
|
1462
|
+
expect(result.nextContext?.session!.stepCount).toBe(8);
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
it('should include execution time in payload', async () => {
|
|
1466
|
+
// Given
|
|
1467
|
+
const mockStore = createMockStore();
|
|
1468
|
+
const context = createTestContext();
|
|
1469
|
+
|
|
1470
|
+
const assistantMessage = createAssistantMessage();
|
|
1471
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1472
|
+
|
|
1473
|
+
const instruction = createCallToolInstruction();
|
|
1474
|
+
const state = createInitialState();
|
|
1475
|
+
|
|
1476
|
+
// When
|
|
1477
|
+
const result = await executeWithMockContext({
|
|
1478
|
+
executor: 'call_tool',
|
|
1479
|
+
instruction,
|
|
1480
|
+
state,
|
|
1481
|
+
mockStore,
|
|
1482
|
+
context,
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
// Then
|
|
1486
|
+
const payload = result.nextContext?.payload as GeneralAgentCallToolResultPayload;
|
|
1487
|
+
expect(payload.executionTime).toBeGreaterThanOrEqual(0);
|
|
1488
|
+
expect(typeof payload.executionTime).toBe('number');
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
it('should include isSuccess flag based on error', async () => {
|
|
1492
|
+
// Given
|
|
1493
|
+
const mockStore = createMockStore({
|
|
1494
|
+
internal_invokeDifferentTypePlugin: vi.fn().mockResolvedValue({ error: 'Failed' }),
|
|
1495
|
+
});
|
|
1496
|
+
const context = createTestContext();
|
|
1497
|
+
|
|
1498
|
+
const assistantMessage = createAssistantMessage();
|
|
1499
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1500
|
+
|
|
1501
|
+
const instruction = createCallToolInstruction();
|
|
1502
|
+
const state = createInitialState();
|
|
1503
|
+
|
|
1504
|
+
// When
|
|
1505
|
+
const result = await executeWithMockContext({
|
|
1506
|
+
executor: 'call_tool',
|
|
1507
|
+
instruction,
|
|
1508
|
+
state,
|
|
1509
|
+
mockStore,
|
|
1510
|
+
context,
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
// Then
|
|
1514
|
+
const payload = result.nextContext?.payload as GeneralAgentCallToolResultPayload;
|
|
1515
|
+
expect(payload.isSuccess).toBe(false);
|
|
1516
|
+
});
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
describe('Edge Cases', () => {
|
|
1520
|
+
it('should handle assistant message without groupId', async () => {
|
|
1521
|
+
// Given
|
|
1522
|
+
const mockStore = createMockStore();
|
|
1523
|
+
const context = createTestContext();
|
|
1524
|
+
|
|
1525
|
+
const assistantMessage = createAssistantMessage({ groupId: undefined });
|
|
1526
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1527
|
+
|
|
1528
|
+
const instruction = createCallToolInstruction();
|
|
1529
|
+
const state = createInitialState();
|
|
1530
|
+
|
|
1531
|
+
// When
|
|
1532
|
+
const result = await executeWithMockContext({
|
|
1533
|
+
executor: 'call_tool',
|
|
1534
|
+
instruction,
|
|
1535
|
+
state,
|
|
1536
|
+
mockStore,
|
|
1537
|
+
context,
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
// Then
|
|
1541
|
+
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
|
|
1542
|
+
expect.objectContaining({
|
|
1543
|
+
groupId: undefined,
|
|
1544
|
+
}),
|
|
1545
|
+
);
|
|
1546
|
+
expect(result.events).toHaveLength(1);
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
it('should handle empty messages array', async () => {
|
|
1550
|
+
// Given
|
|
1551
|
+
const mockStore = createMockStore();
|
|
1552
|
+
const context = createTestContext();
|
|
1553
|
+
|
|
1554
|
+
mockStore.dbMessagesMap[context.messageKey] = [];
|
|
1555
|
+
|
|
1556
|
+
const instruction = createCallToolInstruction();
|
|
1557
|
+
const state = createInitialState();
|
|
1558
|
+
|
|
1559
|
+
// When
|
|
1560
|
+
const result = await executeWithMockContext({
|
|
1561
|
+
executor: 'call_tool',
|
|
1562
|
+
instruction,
|
|
1563
|
+
state,
|
|
1564
|
+
mockStore,
|
|
1565
|
+
context,
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
// Then - should use undefined groupId
|
|
1569
|
+
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
|
|
1570
|
+
expect.objectContaining({
|
|
1571
|
+
groupId: undefined,
|
|
1572
|
+
}),
|
|
1573
|
+
);
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
it('should handle tool with complex nested arguments', async () => {
|
|
1577
|
+
// Given
|
|
1578
|
+
const mockStore = createMockStore();
|
|
1579
|
+
const context = createTestContext();
|
|
1580
|
+
|
|
1581
|
+
const assistantMessage = createAssistantMessage();
|
|
1582
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1583
|
+
|
|
1584
|
+
const toolCall: ChatToolPayload = {
|
|
1585
|
+
id: 'tool_complex_args',
|
|
1586
|
+
identifier: 'custom-plugin',
|
|
1587
|
+
apiName: 'complexApi',
|
|
1588
|
+
arguments: JSON.stringify({
|
|
1589
|
+
nested: {
|
|
1590
|
+
deep: {
|
|
1591
|
+
structure: ['array', 'of', 'values'],
|
|
1592
|
+
number: 42,
|
|
1593
|
+
},
|
|
1594
|
+
},
|
|
1595
|
+
}),
|
|
1596
|
+
type: 'default',
|
|
1597
|
+
};
|
|
1598
|
+
|
|
1599
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
1600
|
+
const state = createInitialState();
|
|
1601
|
+
|
|
1602
|
+
// When
|
|
1603
|
+
const result = await executeWithMockContext({
|
|
1604
|
+
executor: 'call_tool',
|
|
1605
|
+
instruction,
|
|
1606
|
+
state,
|
|
1607
|
+
mockStore,
|
|
1608
|
+
context,
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
// Then
|
|
1612
|
+
expect(result.events).toHaveLength(1);
|
|
1613
|
+
expect(mockStore.internal_invokeDifferentTypePlugin).toHaveBeenCalledWith(
|
|
1614
|
+
expect.any(String),
|
|
1615
|
+
toolCall,
|
|
1616
|
+
);
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
it('should handle null topicId', async () => {
|
|
1620
|
+
// Given
|
|
1621
|
+
const mockStore = createMockStore();
|
|
1622
|
+
const context = createTestContext({ sessionId: 'test-session', topicId: null });
|
|
1623
|
+
|
|
1624
|
+
const assistantMessage = createAssistantMessage();
|
|
1625
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1626
|
+
|
|
1627
|
+
const instruction = createCallToolInstruction();
|
|
1628
|
+
const state = createInitialState();
|
|
1629
|
+
|
|
1630
|
+
// When
|
|
1631
|
+
const result = await executeWithMockContext({
|
|
1632
|
+
executor: 'call_tool',
|
|
1633
|
+
instruction,
|
|
1634
|
+
state,
|
|
1635
|
+
mockStore,
|
|
1636
|
+
context: { ...context, sessionId: 'test-session', topicId: null },
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
// Then
|
|
1640
|
+
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
|
|
1641
|
+
expect.objectContaining({
|
|
1642
|
+
topicId: undefined,
|
|
1643
|
+
}),
|
|
1644
|
+
);
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
it('should handle builtin tool type', async () => {
|
|
1648
|
+
// Given
|
|
1649
|
+
const mockStore = createMockStore();
|
|
1650
|
+
const context = createTestContext();
|
|
1651
|
+
|
|
1652
|
+
const assistantMessage = createAssistantMessage();
|
|
1653
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1654
|
+
|
|
1655
|
+
const toolCall: ChatToolPayload = {
|
|
1656
|
+
id: 'tool_builtin',
|
|
1657
|
+
identifier: 'builtin-search',
|
|
1658
|
+
apiName: 'vectorSearch',
|
|
1659
|
+
arguments: JSON.stringify({ query: 'test' }),
|
|
1660
|
+
type: 'builtin',
|
|
1661
|
+
};
|
|
1662
|
+
|
|
1663
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
1664
|
+
const state = createInitialState();
|
|
1665
|
+
|
|
1666
|
+
// When
|
|
1667
|
+
const result = await executeWithMockContext({
|
|
1668
|
+
executor: 'call_tool',
|
|
1669
|
+
instruction,
|
|
1670
|
+
state,
|
|
1671
|
+
mockStore,
|
|
1672
|
+
context,
|
|
1673
|
+
});
|
|
1674
|
+
|
|
1675
|
+
// Then
|
|
1676
|
+
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
|
|
1677
|
+
expect.objectContaining({
|
|
1678
|
+
plugin: expect.objectContaining({
|
|
1679
|
+
type: 'builtin',
|
|
1680
|
+
}),
|
|
1681
|
+
}),
|
|
1682
|
+
);
|
|
1683
|
+
expect(result.events).toHaveLength(1);
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
it('should handle very long execution time', async () => {
|
|
1687
|
+
// Given
|
|
1688
|
+
const mockStore = createMockStore({
|
|
1689
|
+
internal_invokeDifferentTypePlugin: vi.fn().mockImplementation(async () => {
|
|
1690
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1691
|
+
return { error: null };
|
|
1692
|
+
}),
|
|
1693
|
+
});
|
|
1694
|
+
const context = createTestContext();
|
|
1695
|
+
|
|
1696
|
+
const assistantMessage = createAssistantMessage();
|
|
1697
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1698
|
+
|
|
1699
|
+
const instruction = createCallToolInstruction();
|
|
1700
|
+
const state = createInitialState();
|
|
1701
|
+
|
|
1702
|
+
// When
|
|
1703
|
+
const result = await executeWithMockContext({
|
|
1704
|
+
executor: 'call_tool',
|
|
1705
|
+
instruction,
|
|
1706
|
+
state,
|
|
1707
|
+
mockStore,
|
|
1708
|
+
context,
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
// Then
|
|
1712
|
+
const payload = result.nextContext?.payload as GeneralAgentCallToolResultPayload;
|
|
1713
|
+
expect(payload.executionTime).toBeGreaterThanOrEqual(0);
|
|
1714
|
+
expect(result.newState.usage.tools.totalTimeMs).toBeGreaterThanOrEqual(0);
|
|
1715
|
+
});
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
describe('Multiple User Messages', () => {
|
|
1719
|
+
it('should find last assistant message when multiple messages exist', async () => {
|
|
1720
|
+
// Given
|
|
1721
|
+
const mockStore = createMockStore();
|
|
1722
|
+
const context = createTestContext();
|
|
1723
|
+
|
|
1724
|
+
const messages = [
|
|
1725
|
+
{
|
|
1726
|
+
id: 'msg_user_1',
|
|
1727
|
+
role: 'user',
|
|
1728
|
+
content: 'Hello',
|
|
1729
|
+
createdAt: Date.now(),
|
|
1730
|
+
meta: {},
|
|
1731
|
+
updatedAt: Date.now(),
|
|
1732
|
+
} as any,
|
|
1733
|
+
{
|
|
1734
|
+
id: 'msg_assistant_1',
|
|
1735
|
+
role: 'assistant',
|
|
1736
|
+
content: 'Hi',
|
|
1737
|
+
groupId: 'group_old',
|
|
1738
|
+
createdAt: Date.now(),
|
|
1739
|
+
meta: {},
|
|
1740
|
+
updatedAt: Date.now(),
|
|
1741
|
+
} as any,
|
|
1742
|
+
{
|
|
1743
|
+
id: 'msg_user_2',
|
|
1744
|
+
role: 'user',
|
|
1745
|
+
content: 'Question',
|
|
1746
|
+
createdAt: Date.now(),
|
|
1747
|
+
meta: {},
|
|
1748
|
+
updatedAt: Date.now(),
|
|
1749
|
+
} as any,
|
|
1750
|
+
createAssistantMessage({ groupId: 'group_latest' }),
|
|
1751
|
+
];
|
|
1752
|
+
mockStore.dbMessagesMap[context.messageKey] = messages;
|
|
1753
|
+
|
|
1754
|
+
const instruction = createCallToolInstruction();
|
|
1755
|
+
const state = createInitialState();
|
|
1756
|
+
|
|
1757
|
+
// When
|
|
1758
|
+
await executeWithMockContext({
|
|
1759
|
+
executor: 'call_tool',
|
|
1760
|
+
instruction,
|
|
1761
|
+
state,
|
|
1762
|
+
mockStore,
|
|
1763
|
+
context,
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
// Then - should use the latest assistant message's groupId
|
|
1767
|
+
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
|
|
1768
|
+
expect.objectContaining({
|
|
1769
|
+
groupId: 'group_latest',
|
|
1770
|
+
}),
|
|
1771
|
+
);
|
|
1772
|
+
});
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
describe('High Priority Coverage - Cancellation Scenarios', () => {
|
|
1776
|
+
it('should skip tool execution when parent operation is cancelled after message creation', async () => {
|
|
1777
|
+
// Given
|
|
1778
|
+
const mockStore = createMockStore();
|
|
1779
|
+
const context = createTestContext({ sessionId: 'cancel-test', topicId: 'topic-test' });
|
|
1780
|
+
|
|
1781
|
+
const assistantMessage = createAssistantMessage();
|
|
1782
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1783
|
+
|
|
1784
|
+
const toolCall: ChatToolPayload = {
|
|
1785
|
+
id: 'tool_cancel_after_msg',
|
|
1786
|
+
identifier: 'test-plugin',
|
|
1787
|
+
apiName: 'test-api',
|
|
1788
|
+
arguments: JSON.stringify({ param: 'value' }),
|
|
1789
|
+
type: 'default',
|
|
1790
|
+
};
|
|
1791
|
+
|
|
1792
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
1793
|
+
const state = createInitialState();
|
|
1794
|
+
|
|
1795
|
+
// Mock: Simulate parent operation being cancelled after createToolMessage completes
|
|
1796
|
+
let toolCallingOpId: string;
|
|
1797
|
+
const originalCompleteOperation = mockStore.completeOperation;
|
|
1798
|
+
mockStore.completeOperation = vi.fn((opId: string) => {
|
|
1799
|
+
originalCompleteOperation(opId);
|
|
1800
|
+
// Check if this is the createToolMessage operation completing
|
|
1801
|
+
const op = mockStore.operations[opId];
|
|
1802
|
+
if (op?.type === 'createToolMessage') {
|
|
1803
|
+
// Abort parent toolCalling operation right after message creation completes
|
|
1804
|
+
if (toolCallingOpId) {
|
|
1805
|
+
const parentOp = mockStore.operations[toolCallingOpId];
|
|
1806
|
+
if (parentOp) {
|
|
1807
|
+
parentOp.abortController.abort();
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
const originalStartOperation = mockStore.startOperation;
|
|
1814
|
+
mockStore.startOperation = vi.fn((config: any) => {
|
|
1815
|
+
const result = originalStartOperation(config);
|
|
1816
|
+
if (config.type === 'toolCalling') {
|
|
1817
|
+
toolCallingOpId = result.operationId;
|
|
1818
|
+
}
|
|
1819
|
+
return result;
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
// When
|
|
1823
|
+
const result = await executeWithMockContext({
|
|
1824
|
+
executor: 'call_tool',
|
|
1825
|
+
instruction,
|
|
1826
|
+
state,
|
|
1827
|
+
mockStore,
|
|
1828
|
+
context,
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
// Then
|
|
1832
|
+
// Should return early without executing the tool
|
|
1833
|
+
expect(result.events).toHaveLength(0);
|
|
1834
|
+
// Should have created tool message but not executed it
|
|
1835
|
+
expect(mockStore.optimisticCreateMessage).toHaveBeenCalled();
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
it('should handle executeToolCall cancellation and update message to aborted state', async () => {
|
|
1839
|
+
// Given
|
|
1840
|
+
const mockStore = createMockStore();
|
|
1841
|
+
const context = createTestContext();
|
|
1842
|
+
|
|
1843
|
+
const assistantMessage = createAssistantMessage();
|
|
1844
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1845
|
+
|
|
1846
|
+
const toolCall: ChatToolPayload = {
|
|
1847
|
+
id: 'tool_exec_cancel',
|
|
1848
|
+
identifier: 'slow-tool',
|
|
1849
|
+
apiName: 'slow-operation',
|
|
1850
|
+
arguments: JSON.stringify({ delay: 5000 }),
|
|
1851
|
+
type: 'default',
|
|
1852
|
+
};
|
|
1853
|
+
|
|
1854
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
1855
|
+
const state = createInitialState();
|
|
1856
|
+
|
|
1857
|
+
// Track cancel handler registration
|
|
1858
|
+
let executeToolCancelHandler:
|
|
1859
|
+
| ((context: OperationCancelContext) => void | Promise<void>)
|
|
1860
|
+
| undefined;
|
|
1861
|
+
const originalOnOperationCancel = mockStore.onOperationCancel;
|
|
1862
|
+
mockStore.onOperationCancel = vi.fn(
|
|
1863
|
+
(opId: string, handler: (context: OperationCancelContext) => void | Promise<void>) => {
|
|
1864
|
+
const op = mockStore.operations[opId];
|
|
1865
|
+
if (op?.type === 'executeToolCall') {
|
|
1866
|
+
executeToolCancelHandler = handler;
|
|
1867
|
+
}
|
|
1868
|
+
return originalOnOperationCancel(opId, handler);
|
|
1869
|
+
},
|
|
1870
|
+
);
|
|
1871
|
+
|
|
1872
|
+
// When
|
|
1873
|
+
await executeWithMockContext({
|
|
1874
|
+
executor: 'call_tool',
|
|
1875
|
+
instruction,
|
|
1876
|
+
state,
|
|
1877
|
+
mockStore,
|
|
1878
|
+
context,
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
// Then
|
|
1882
|
+
expect(executeToolCancelHandler).toBeDefined();
|
|
1883
|
+
|
|
1884
|
+
// Verify cancel handler updates message correctly
|
|
1885
|
+
if (executeToolCancelHandler) {
|
|
1886
|
+
await executeToolCancelHandler({
|
|
1887
|
+
operationId: 'exec-op-id',
|
|
1888
|
+
type: 'executeToolCall',
|
|
1889
|
+
reason: 'user_cancelled',
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
// Should update message content
|
|
1893
|
+
expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith(
|
|
1894
|
+
expect.any(String),
|
|
1895
|
+
'Tool execution was cancelled by user.',
|
|
1896
|
+
undefined,
|
|
1897
|
+
expect.objectContaining({ operationId: expect.any(String) }),
|
|
1898
|
+
);
|
|
1899
|
+
|
|
1900
|
+
// Should update plugin intervention status
|
|
1901
|
+
expect(mockStore.optimisticUpdateMessagePlugin).toHaveBeenCalledWith(
|
|
1902
|
+
expect.any(String),
|
|
1903
|
+
{ intervention: { status: 'aborted' } },
|
|
1904
|
+
expect.objectContaining({ operationId: expect.any(String) }),
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
it('should skip completion when tool execution finishes after operation was cancelled', async () => {
|
|
1910
|
+
// Given
|
|
1911
|
+
const mockStore = createMockStore();
|
|
1912
|
+
const context = createTestContext();
|
|
1913
|
+
|
|
1914
|
+
const assistantMessage = createAssistantMessage();
|
|
1915
|
+
mockStore.dbMessagesMap[context.messageKey] = [assistantMessage];
|
|
1916
|
+
|
|
1917
|
+
const toolCall: ChatToolPayload = {
|
|
1918
|
+
id: 'tool_cancelled_during_exec',
|
|
1919
|
+
identifier: 'test-plugin',
|
|
1920
|
+
apiName: 'test-api',
|
|
1921
|
+
arguments: JSON.stringify({ param: 'value' }),
|
|
1922
|
+
type: 'default',
|
|
1923
|
+
};
|
|
1924
|
+
|
|
1925
|
+
const instruction = createCallToolInstruction(toolCall);
|
|
1926
|
+
const state = createInitialState();
|
|
1927
|
+
|
|
1928
|
+
// Mock: Simulate operation being cancelled during tool execution
|
|
1929
|
+
let executeToolOpId: string | undefined;
|
|
1930
|
+
const originalStartOperation = mockStore.startOperation;
|
|
1931
|
+
mockStore.startOperation = vi.fn((config: any) => {
|
|
1932
|
+
const result = originalStartOperation(config);
|
|
1933
|
+
if (config.type === 'executeToolCall') {
|
|
1934
|
+
executeToolOpId = result.operationId;
|
|
1935
|
+
}
|
|
1936
|
+
return result;
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
// Mock internal_invokeDifferentTypePlugin to abort operation before returning
|
|
1940
|
+
const originalInvoke = mockStore.internal_invokeDifferentTypePlugin;
|
|
1941
|
+
mockStore.internal_invokeDifferentTypePlugin = vi.fn(
|
|
1942
|
+
async (messageId: string, payload: ChatToolPayload) => {
|
|
1943
|
+
const result = await originalInvoke(messageId, payload);
|
|
1944
|
+
// Simulate cancellation happening during tool execution
|
|
1945
|
+
if (executeToolOpId) {
|
|
1946
|
+
const op = mockStore.operations[executeToolOpId];
|
|
1947
|
+
if (op) {
|
|
1948
|
+
op.abortController.abort();
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
return result;
|
|
1952
|
+
},
|
|
1953
|
+
);
|
|
1954
|
+
|
|
1955
|
+
// When
|
|
1956
|
+
const result = await executeWithMockContext({
|
|
1957
|
+
executor: 'call_tool',
|
|
1958
|
+
instruction,
|
|
1959
|
+
state,
|
|
1960
|
+
mockStore,
|
|
1961
|
+
context,
|
|
1962
|
+
});
|
|
1963
|
+
|
|
1964
|
+
// Then
|
|
1965
|
+
// Should return early without completing operation or logging success
|
|
1966
|
+
expect(result.events).toHaveLength(0);
|
|
1967
|
+
// Should have executed the tool
|
|
1968
|
+
expect(mockStore.internal_invokeDifferentTypePlugin).toHaveBeenCalled();
|
|
1969
|
+
// Should not have completed the executeToolCall operation (because it was aborted)
|
|
1970
|
+
if (executeToolOpId) {
|
|
1971
|
+
const executeToolOp = mockStore.operations[executeToolOpId];
|
|
1972
|
+
expect(executeToolOp?.status).not.toBe('completed');
|
|
1973
|
+
}
|
|
1974
|
+
});
|
|
1975
|
+
});
|
|
1976
|
+
});
|