@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.
Files changed (95) 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/components/Analytics/MainInterfaceTracker.tsx +2 -2
  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/MarkdownElements/LobeThinking/Render.tsx +3 -3
  24. package/src/features/Conversation/MarkdownElements/Thinking/Render.tsx +3 -3
  25. package/src/features/Conversation/Messages/Group/Tool/Inspector/index.tsx +1 -1
  26. package/src/features/Conversation/Messages/User/index.tsx +3 -3
  27. package/src/features/Conversation/Messages/index.tsx +3 -3
  28. package/src/features/Conversation/components/AutoScroll.tsx +2 -2
  29. package/src/features/PluginsUI/Render/StandaloneType/Iframe.tsx +3 -3
  30. package/src/features/Portal/Home/Body/Plugins/ArtifactList/index.tsx +3 -3
  31. package/src/features/ShareModal/ShareText/index.tsx +3 -3
  32. package/src/services/search.ts +2 -2
  33. package/src/store/chat/agents/GeneralChatAgent.ts +98 -0
  34. package/src/store/chat/agents/__tests__/GeneralChatAgent.test.ts +366 -0
  35. package/src/store/chat/agents/__tests__/createAgentExecutors/call-llm.test.ts +1217 -0
  36. package/src/store/chat/agents/__tests__/createAgentExecutors/call-tool.test.ts +1976 -0
  37. package/src/store/chat/agents/__tests__/createAgentExecutors/finish.test.ts +453 -0
  38. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/index.ts +4 -0
  39. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockInstructions.ts +126 -0
  40. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockMessages.ts +94 -0
  41. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockOperations.ts +96 -0
  42. package/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockStore.ts +138 -0
  43. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/assertions.ts +185 -0
  44. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/index.ts +3 -0
  45. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/operationTestUtils.ts +94 -0
  46. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/testExecutor.ts +139 -0
  47. package/src/store/chat/agents/__tests__/createAgentExecutors/request-human-approve.test.ts +545 -0
  48. package/src/store/chat/agents/__tests__/createAgentExecutors/resolve-aborted-tools.test.ts +686 -0
  49. package/src/store/chat/agents/createAgentExecutors.ts +313 -80
  50. package/src/store/chat/selectors.ts +1 -0
  51. package/src/store/chat/slices/aiChat/__tests__/ai-chat.integration.test.ts +667 -0
  52. package/src/store/chat/slices/aiChat/actions/__tests__/cancel-functionality.test.ts +137 -27
  53. package/src/store/chat/slices/aiChat/actions/__tests__/conversationControl.test.ts +163 -125
  54. package/src/store/chat/slices/aiChat/actions/__tests__/conversationLifecycle.test.ts +12 -2
  55. package/src/store/chat/slices/aiChat/actions/__tests__/fixtures.ts +0 -2
  56. package/src/store/chat/slices/aiChat/actions/__tests__/helpers.ts +0 -2
  57. package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +286 -19
  58. package/src/store/chat/slices/aiChat/actions/__tests__/streamingStates.test.ts +0 -112
  59. package/src/store/chat/slices/aiChat/actions/conversationControl.ts +42 -99
  60. package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +90 -57
  61. package/src/store/chat/slices/aiChat/actions/generateAIGroupChat.ts +5 -25
  62. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +220 -98
  63. package/src/store/chat/slices/aiChat/actions/streamingStates.ts +0 -34
  64. package/src/store/chat/slices/aiChat/initialState.ts +0 -28
  65. package/src/store/chat/slices/aiChat/selectors.test.ts +280 -0
  66. package/src/store/chat/slices/aiChat/selectors.ts +31 -7
  67. package/src/store/chat/slices/builtinTool/actions/__tests__/localSystem.test.ts +21 -30
  68. package/src/store/chat/slices/builtinTool/actions/__tests__/search.test.ts +29 -49
  69. package/src/store/chat/slices/builtinTool/actions/interpreter.ts +83 -48
  70. package/src/store/chat/slices/builtinTool/actions/localSystem.ts +78 -28
  71. package/src/store/chat/slices/builtinTool/actions/search.ts +146 -59
  72. package/src/store/chat/slices/builtinTool/selectors.test.ts +258 -0
  73. package/src/store/chat/slices/builtinTool/selectors.ts +25 -4
  74. package/src/store/chat/slices/message/action.test.ts +134 -16
  75. package/src/store/chat/slices/message/actions/internals.ts +33 -7
  76. package/src/store/chat/slices/message/actions/optimisticUpdate.ts +85 -52
  77. package/src/store/chat/slices/message/initialState.ts +0 -10
  78. package/src/store/chat/slices/message/selectors/messageState.ts +34 -12
  79. package/src/store/chat/slices/operation/__tests__/actions.test.ts +712 -16
  80. package/src/store/chat/slices/operation/__tests__/integration.test.ts +342 -0
  81. package/src/store/chat/slices/operation/__tests__/selectors.test.ts +257 -17
  82. package/src/store/chat/slices/operation/actions.ts +218 -11
  83. package/src/store/chat/slices/operation/selectors.ts +135 -6
  84. package/src/store/chat/slices/operation/types.ts +29 -3
  85. package/src/store/chat/slices/plugin/action.test.ts +30 -322
  86. package/src/store/chat/slices/plugin/actions/internals.ts +0 -14
  87. package/src/store/chat/slices/plugin/actions/optimisticUpdate.ts +21 -19
  88. package/src/store/chat/slices/plugin/actions/pluginTypes.ts +45 -27
  89. package/src/store/chat/slices/plugin/actions/publicApi.ts +3 -4
  90. package/src/store/chat/slices/plugin/actions/workflow.ts +0 -55
  91. package/src/store/chat/slices/thread/selectors/index.ts +4 -2
  92. package/src/store/chat/slices/topic/action.ts +3 -3
  93. package/src/store/chat/slices/translate/action.ts +54 -41
  94. package/src/tools/web-browsing/ExecutionRuntime/index.ts +5 -2
  95. 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
+ });