@vibe-forge/mcp 3.1.1 → 3.2.0

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.
@@ -1,871 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest'
2
-
3
- const mocks = vi.hoisted(() => ({
4
- run: vi.fn(),
5
- generateAdapterQueryOptions: vi.fn(),
6
- callHook: vi.fn(),
7
- buildConfigJsonVariables: vi.fn(),
8
- loadConfig: vi.fn(),
9
- updateConfigFile: vi.fn(),
10
- loadInjectDefaultSystemPromptValue: vi.fn(),
11
- mergeSystemPrompts: vi.fn(),
12
- extractTextFromMessage: vi.fn(),
13
- postSessionEvent: vi.fn(),
14
- fetchSessionMessages: vi.fn(),
15
- mkdir: vi.fn(),
16
- writeFile: vi.fn()
17
- }))
18
-
19
- vi.mock('@vibe-forge/task', () => ({
20
- run: mocks.run,
21
- generateAdapterQueryOptions: mocks.generateAdapterQueryOptions
22
- }))
23
-
24
- vi.mock('@vibe-forge/hooks', () => ({
25
- callHook: mocks.callHook
26
- }))
27
-
28
- vi.mock('@vibe-forge/config', () => ({
29
- buildConfigJsonVariables: mocks.buildConfigJsonVariables,
30
- loadConfig: mocks.loadConfig,
31
- updateConfigFile: mocks.updateConfigFile,
32
- loadInjectDefaultSystemPromptValue: mocks.loadInjectDefaultSystemPromptValue,
33
- mergeSystemPrompts: mocks.mergeSystemPrompts
34
- }))
35
-
36
- vi.mock('@vibe-forge/utils/chat-message', () => ({
37
- extractTextFromMessage: mocks.extractTextFromMessage
38
- }))
39
-
40
- vi.mock('node:fs/promises', () => ({
41
- mkdir: mocks.mkdir,
42
- writeFile: mocks.writeFile
43
- }))
44
-
45
- vi.mock('#~/sync.js', () => ({
46
- postSessionEvent: mocks.postSessionEvent,
47
- fetchSessionMessages: mocks.fetchSessionMessages
48
- }))
49
-
50
- describe('taskManager fatal error scenarios', () => {
51
- beforeEach(() => {
52
- vi.clearAllMocks()
53
- mocks.generateAdapterQueryOptions.mockResolvedValue([
54
- {},
55
- {
56
- systemPrompt: undefined,
57
- tools: undefined,
58
- skills: undefined,
59
- mcpServers: undefined
60
- }
61
- ])
62
- mocks.buildConfigJsonVariables.mockReturnValue({})
63
- mocks.loadConfig.mockResolvedValue([undefined, undefined])
64
- mocks.updateConfigFile.mockResolvedValue(undefined)
65
- mocks.callHook.mockResolvedValue(undefined)
66
- mocks.loadInjectDefaultSystemPromptValue.mockResolvedValue(true)
67
- mocks.mergeSystemPrompts.mockReturnValue(undefined)
68
- mocks.postSessionEvent.mockResolvedValue(undefined)
69
- mocks.fetchSessionMessages.mockResolvedValue([])
70
- mocks.extractTextFromMessage.mockReturnValue('')
71
- mocks.mkdir.mockResolvedValue(undefined)
72
- mocks.writeFile.mockResolvedValue(undefined)
73
- })
74
-
75
- it('keeps the task failed when a fatal error is followed by stop', async () => {
76
- const { TaskManager } = await import('#~/tools/task/manager.js')
77
-
78
- mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
79
- const session = {
80
- emit: vi.fn(() => {
81
- adapterOptions.onEvent({
82
- type: 'error',
83
- data: {
84
- message: 'Incomplete response returned',
85
- fatal: true
86
- }
87
- })
88
- adapterOptions.onEvent({
89
- type: 'stop',
90
- data: undefined
91
- })
92
- }),
93
- kill: vi.fn()
94
- }
95
- return { session }
96
- })
97
-
98
- const managedTaskManager = new TaskManager()
99
- await managedTaskManager.startTask({
100
- taskId: 'task-fatal-stop',
101
- description: 'trigger',
102
- background: false
103
- })
104
-
105
- const task = managedTaskManager.getTask('task-fatal-stop')
106
- expect(task?.status).toBe('failed')
107
- expect(task?.logs).toContain('Incomplete response returned')
108
- })
109
-
110
- it('surfaces pending interactions in logs and task state', async () => {
111
- const { TaskManager } = await import('#~/tools/task/manager.js')
112
-
113
- mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
114
- const session = {
115
- emit: vi.fn(() => {
116
- adapterOptions.onEvent({
117
- type: 'interaction_request',
118
- data: {
119
- id: 'interaction-1',
120
- payload: {
121
- sessionId: 'task-waiting',
122
- kind: 'permission',
123
- question: 'Allow editing files?',
124
- options: [
125
- { label: 'Allow once', value: 'allow_once' },
126
- { label: 'Deny once', value: 'deny_once' }
127
- ]
128
- }
129
- }
130
- })
131
- }),
132
- kill: vi.fn(),
133
- respondInteraction: vi.fn()
134
- }
135
- return { session }
136
- })
137
-
138
- const managedTaskManager = new TaskManager()
139
- const result = await managedTaskManager.startTask({
140
- taskId: 'task-waiting',
141
- description: 'trigger',
142
- background: false
143
- })
144
-
145
- const task = managedTaskManager.getTask('task-waiting')
146
- expect(task?.status).toBe('waiting_input')
147
- expect(task?.pendingInteraction).toEqual({
148
- id: 'interaction-1',
149
- payload: expect.objectContaining({
150
- question: 'Allow editing files?'
151
- }),
152
- source: 'adapter'
153
- })
154
- expect(result.logs).toContain(
155
- 'Waiting for permission input: Allow editing files? Available responses: allow_once, deny_once.'
156
- )
157
- })
158
-
159
- it('forwards task model overrides into query resolution and runtime startup', async () => {
160
- const { TaskManager } = await import('#~/tools/task/manager.js')
161
-
162
- mocks.run.mockResolvedValueOnce({
163
- session: {
164
- emit: vi.fn(),
165
- kill: vi.fn()
166
- }
167
- })
168
-
169
- const managedTaskManager = new TaskManager()
170
- await managedTaskManager.startTask({
171
- taskId: 'task-model-override',
172
- description: 'trigger',
173
- adapter: 'codex',
174
- model: 'openai,gpt-5.4-mini'
175
- })
176
-
177
- expect(mocks.generateAdapterQueryOptions).toHaveBeenCalledWith(
178
- undefined,
179
- undefined,
180
- expect.any(String),
181
- expect.objectContaining({
182
- adapter: 'codex',
183
- model: 'openai,gpt-5.4-mini'
184
- })
185
- )
186
- expect(mocks.run).toHaveBeenCalledWith(
187
- expect.objectContaining({
188
- adapter: 'codex'
189
- }),
190
- expect.objectContaining({
191
- sessionId: 'task-model-override',
192
- model: 'openai,gpt-5.4-mini'
193
- })
194
- )
195
- expect(managedTaskManager.getTask('task-model-override')).toEqual(expect.objectContaining({
196
- model: 'openai,gpt-5.4-mini'
197
- }))
198
- })
199
-
200
- it('uses the task id as the adapter cache context instead of an inherited parent context', async () => {
201
- process.env.__VF_PROJECT_AI_CTX_ID__ = 'parent-ctx'
202
- const { TaskManager } = await import('#~/tools/task/manager.js')
203
-
204
- mocks.run.mockResolvedValueOnce({
205
- session: {
206
- emit: vi.fn(),
207
- kill: vi.fn()
208
- }
209
- })
210
-
211
- try {
212
- const managedTaskManager = new TaskManager()
213
- await managedTaskManager.startTask({
214
- taskId: 'task-stable-context',
215
- description: 'trigger',
216
- adapter: 'codex'
217
- })
218
- } finally {
219
- delete process.env.__VF_PROJECT_AI_CTX_ID__
220
- }
221
-
222
- expect(mocks.run).toHaveBeenCalledWith(
223
- expect.objectContaining({
224
- env: expect.objectContaining({
225
- __VF_PROJECT_AI_CTX_ID__: 'task-stable-context'
226
- })
227
- }),
228
- expect.objectContaining({
229
- sessionId: 'task-stable-context'
230
- })
231
- )
232
- })
233
-
234
- it('responds to pending interactions and syncs the response', async () => {
235
- const { TaskManager } = await import('#~/tools/task/manager.js')
236
- const respondInteraction = vi.fn()
237
-
238
- mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
239
- const session = {
240
- emit: vi.fn(() => {
241
- adapterOptions.onEvent({
242
- type: 'interaction_request',
243
- data: {
244
- id: 'interaction-2',
245
- payload: {
246
- sessionId: 'task-respond',
247
- kind: 'permission',
248
- question: 'Allow bash?',
249
- options: [
250
- { label: 'Allow once', value: 'allow_once' }
251
- ]
252
- }
253
- }
254
- })
255
- }),
256
- kill: vi.fn(),
257
- respondInteraction
258
- }
259
- return { session }
260
- })
261
-
262
- const managedTaskManager = new TaskManager()
263
- await managedTaskManager.startTask({
264
- taskId: 'task-respond',
265
- description: 'trigger',
266
- enableServerSync: true
267
- })
268
-
269
- await managedTaskManager.respondToTaskInteraction({
270
- taskId: 'task-respond',
271
- data: 'allow_once'
272
- })
273
-
274
- const task = managedTaskManager.getTask('task-respond')
275
- expect(respondInteraction).toHaveBeenCalledWith('interaction-2', 'allow_once')
276
- expect(task?.status).toBe('running')
277
- expect(task?.pendingInteraction).toBeUndefined()
278
- expect(task?.logs).toContain('Interaction response submitted: allow_once')
279
- expect(mocks.postSessionEvent).toHaveBeenCalledWith('task-respond', {
280
- type: 'interaction_response',
281
- id: 'interaction-2',
282
- data: 'allow_once'
283
- })
284
- })
285
-
286
- it('sends a follow-up message directly to a running task without server sync', async () => {
287
- const { TaskManager } = await import('#~/tools/task/manager.js')
288
- const emit = vi.fn()
289
-
290
- mocks.run.mockResolvedValueOnce({
291
- session: {
292
- emit,
293
- kill: vi.fn()
294
- }
295
- })
296
-
297
- const managedTaskManager = new TaskManager()
298
- await managedTaskManager.startTask({
299
- taskId: 'task-send-local',
300
- description: 'trigger'
301
- })
302
-
303
- await managedTaskManager.sendTaskMessage({
304
- taskId: 'task-send-local',
305
- message: 'keep going'
306
- })
307
-
308
- const task = managedTaskManager.getTask('task-send-local')
309
- expect(emit).toHaveBeenNthCalledWith(2, {
310
- type: 'message',
311
- content: [{
312
- type: 'text',
313
- text: 'keep going'
314
- }]
315
- })
316
- expect(task?.logs).toContain('User message submitted (direct): keep going')
317
- })
318
-
319
- it('syncs follow-up messages through the child session when server sync is enabled', async () => {
320
- const { TaskManager } = await import('#~/tools/task/manager.js')
321
- const emit = vi.fn()
322
-
323
- mocks.run.mockResolvedValueOnce({
324
- session: {
325
- emit,
326
- kill: vi.fn()
327
- }
328
- })
329
-
330
- const managedTaskManager = new TaskManager()
331
- await managedTaskManager.startTask({
332
- taskId: 'task-send-synced',
333
- description: 'trigger',
334
- enableServerSync: true
335
- })
336
-
337
- await managedTaskManager.sendTaskMessage({
338
- taskId: 'task-send-synced',
339
- message: 'keep going'
340
- })
341
-
342
- const task = managedTaskManager.getTask('task-send-synced')
343
- expect(emit).toHaveBeenNthCalledWith(2, {
344
- type: 'message',
345
- content: [{
346
- type: 'text',
347
- text: 'keep going'
348
- }]
349
- })
350
- expect(mocks.postSessionEvent).toHaveBeenCalledWith('task-send-synced', {
351
- type: 'message',
352
- data: expect.objectContaining({
353
- role: 'user',
354
- content: 'keep going'
355
- })
356
- })
357
- expect(task?.logs).toContain('User message submitted (direct): keep going')
358
- })
359
-
360
- it('does not replay stale synced messages after a failed resume attempt', async () => {
361
- const { TaskManager } = await import('#~/tools/task/manager.js')
362
- let onEvent: ((event: any) => void) | undefined
363
- const resumedEmit = vi.fn()
364
- const syncedEvents: Array<{ type: 'message'; message: Record<string, unknown> }> = []
365
-
366
- mocks.postSessionEvent.mockImplementation(async (_sessionId: string, payload: any) => {
367
- if (payload?.type === 'message' && payload.data != null) {
368
- syncedEvents.push({
369
- type: 'message',
370
- message: payload.data
371
- })
372
- }
373
- })
374
- mocks.fetchSessionMessages.mockImplementation(async () => syncedEvents)
375
-
376
- mocks.run
377
- .mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
378
- onEvent = adapterOptions.onEvent
379
- return {
380
- session: {
381
- emit: vi.fn(),
382
- kill: vi.fn()
383
- }
384
- }
385
- })
386
- .mockRejectedValueOnce(new Error('resume failed to start'))
387
- .mockResolvedValueOnce({
388
- session: {
389
- emit: resumedEmit,
390
- kill: vi.fn()
391
- }
392
- })
393
-
394
- const managedTaskManager = new TaskManager()
395
- await managedTaskManager.startTask({
396
- taskId: 'task-send-synced-retry',
397
- description: 'trigger',
398
- enableServerSync: true
399
- })
400
-
401
- onEvent?.({
402
- type: 'stop',
403
- data: undefined
404
- })
405
-
406
- await expect(managedTaskManager.sendTaskMessage({
407
- taskId: 'task-send-synced-retry',
408
- message: 'first retry'
409
- })).rejects.toThrow('resume failed to start')
410
-
411
- const firstMessage = syncedEvents.at(-1)?.message
412
- const taskAfterFailure = managedTaskManager.getTask('task-send-synced-retry')
413
- expect(firstMessage).toEqual(expect.objectContaining({
414
- id: expect.any(String),
415
- role: 'user',
416
- content: 'first retry'
417
- }))
418
- expect(taskAfterFailure?.serverSync?.seenMessageIds.has(String(firstMessage?.id))).toBe(true)
419
-
420
- await managedTaskManager.sendTaskMessage({
421
- taskId: 'task-send-synced-retry',
422
- message: 'second retry'
423
- })
424
- await new Promise(resolve => setTimeout(resolve, 0))
425
-
426
- expect(resumedEmit).toHaveBeenCalledTimes(1)
427
- expect(resumedEmit).toHaveBeenCalledWith({
428
- type: 'message',
429
- content: [{
430
- type: 'text',
431
- text: 'second retry'
432
- }]
433
- })
434
-
435
- managedTaskManager.stopTask('task-send-synced-retry')
436
- })
437
-
438
- it('queues steer follow-up messages and resumes the same task after natural completion', async () => {
439
- const { TaskManager } = await import('#~/tools/task/manager.js')
440
- let onEvent: ((event: any) => void) | undefined
441
- const resumedEmit = vi.fn()
442
-
443
- mocks.run
444
- .mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
445
- onEvent = adapterOptions.onEvent
446
- return {
447
- session: {
448
- emit: vi.fn(),
449
- kill: vi.fn()
450
- }
451
- }
452
- })
453
- .mockResolvedValueOnce({
454
- session: {
455
- emit: resumedEmit,
456
- kill: vi.fn()
457
- }
458
- })
459
-
460
- const managedTaskManager = new TaskManager()
461
- await managedTaskManager.startTask({
462
- taskId: 'task-send-steer',
463
- description: 'trigger'
464
- })
465
-
466
- await managedTaskManager.sendTaskMessage({
467
- taskId: 'task-send-steer',
468
- message: 'after you finish, summarize blockers',
469
- mode: 'steer'
470
- })
471
-
472
- onEvent?.({
473
- type: 'stop',
474
- data: undefined
475
- })
476
- await new Promise(resolve => setTimeout(resolve, 0))
477
-
478
- const task = managedTaskManager.getTask('task-send-steer')
479
- expect(mocks.run).toHaveBeenCalledTimes(2)
480
- expect(resumedEmit).toHaveBeenCalledWith({
481
- type: 'message',
482
- content: [{
483
- type: 'text',
484
- text: 'after you finish, summarize blockers'
485
- }]
486
- })
487
- expect(task?.logs).toContain('Queued task message (steer): after you finish, summarize blockers')
488
- expect(task?.logs).toContain('Resuming task from steer queue: after you finish, summarize blockers')
489
- })
490
-
491
- it('rejects follow-up messages when a task is waiting for input', async () => {
492
- const { TaskManager } = await import('#~/tools/task/manager.js')
493
-
494
- mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
495
- const session = {
496
- emit: vi.fn(() => {
497
- adapterOptions.onEvent({
498
- type: 'interaction_request',
499
- data: {
500
- id: 'interaction-send-blocked',
501
- payload: {
502
- sessionId: 'task-send-blocked',
503
- kind: 'permission',
504
- question: 'Allow editing files?',
505
- options: [
506
- { label: 'Allow once', value: 'allow_once' }
507
- ]
508
- }
509
- }
510
- })
511
- }),
512
- kill: vi.fn(),
513
- respondInteraction: vi.fn()
514
- }
515
- return { session }
516
- })
517
-
518
- const managedTaskManager = new TaskManager()
519
- await managedTaskManager.startTask({
520
- taskId: 'task-send-blocked',
521
- description: 'trigger',
522
- background: false
523
- })
524
-
525
- await expect(managedTaskManager.sendTaskMessage({
526
- taskId: 'task-send-blocked',
527
- message: 'continue'
528
- })).rejects.toThrow('Task task-send-blocked is waiting for input. Use SubmitTaskInput instead.')
529
- })
530
-
531
- it('rejects steer follow-up messages when a task is waiting for input', async () => {
532
- const { TaskManager } = await import('#~/tools/task/manager.js')
533
-
534
- mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
535
- const session = {
536
- emit: vi.fn(() => {
537
- adapterOptions.onEvent({
538
- type: 'interaction_request',
539
- data: {
540
- id: 'interaction-send-steer-blocked',
541
- payload: {
542
- sessionId: 'task-send-steer-blocked',
543
- kind: 'permission',
544
- question: 'Allow editing files?',
545
- options: [
546
- { label: 'Allow once', value: 'allow_once' }
547
- ]
548
- }
549
- }
550
- })
551
- }),
552
- kill: vi.fn(),
553
- respondInteraction: vi.fn()
554
- }
555
- return { session }
556
- })
557
-
558
- const managedTaskManager = new TaskManager()
559
- await managedTaskManager.startTask({
560
- taskId: 'task-send-steer-blocked',
561
- description: 'trigger',
562
- background: false
563
- })
564
-
565
- await expect(managedTaskManager.sendTaskMessage({
566
- taskId: 'task-send-steer-blocked',
567
- message: 'summarize blockers after this',
568
- mode: 'steer'
569
- })).rejects.toThrow('Task task-send-steer-blocked is waiting for input. Use SubmitTaskInput instead.')
570
- })
571
-
572
- it('resumes completed tasks when sending a direct follow-up message', async () => {
573
- const { TaskManager } = await import('#~/tools/task/manager.js')
574
- let onEvent: ((event: any) => void) | undefined
575
- const resumedEmit = vi.fn()
576
-
577
- mocks.run
578
- .mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
579
- onEvent = adapterOptions.onEvent
580
- return {
581
- session: {
582
- emit: vi.fn(),
583
- kill: vi.fn()
584
- }
585
- }
586
- })
587
- .mockResolvedValueOnce({
588
- session: {
589
- emit: resumedEmit,
590
- kill: vi.fn()
591
- }
592
- })
593
-
594
- const managedTaskManager = new TaskManager()
595
- await managedTaskManager.startTask({
596
- taskId: 'task-send-completed-direct',
597
- description: 'trigger'
598
- })
599
-
600
- onEvent?.({
601
- type: 'stop',
602
- data: undefined
603
- })
604
-
605
- await managedTaskManager.sendTaskMessage({
606
- taskId: 'task-send-completed-direct',
607
- message: 'continue from the final summary'
608
- })
609
-
610
- const task = managedTaskManager.getTask('task-send-completed-direct')
611
- expect(mocks.run).toHaveBeenCalledTimes(2)
612
- expect(resumedEmit).toHaveBeenCalledWith({
613
- type: 'message',
614
- content: [{
615
- type: 'text',
616
- text: 'continue from the final summary'
617
- }]
618
- })
619
- expect(task?.logs).toContain('Resuming inactive task (direct): continue from the final summary')
620
- })
621
-
622
- it('resumes completed tasks when sending a steer follow-up message', async () => {
623
- const { TaskManager } = await import('#~/tools/task/manager.js')
624
- let onEvent: ((event: any) => void) | undefined
625
- const resumedEmit = vi.fn()
626
-
627
- mocks.run
628
- .mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
629
- onEvent = adapterOptions.onEvent
630
- return {
631
- session: {
632
- emit: vi.fn(),
633
- kill: vi.fn()
634
- }
635
- }
636
- })
637
- .mockResolvedValueOnce({
638
- session: {
639
- emit: resumedEmit,
640
- kill: vi.fn()
641
- }
642
- })
643
-
644
- const managedTaskManager = new TaskManager()
645
- await managedTaskManager.startTask({
646
- taskId: 'task-send-completed-steer',
647
- description: 'trigger'
648
- })
649
-
650
- onEvent?.({
651
- type: 'stop',
652
- data: undefined
653
- })
654
-
655
- await managedTaskManager.sendTaskMessage({
656
- taskId: 'task-send-completed-steer',
657
- message: 'queue this for later',
658
- mode: 'steer'
659
- })
660
-
661
- const task = managedTaskManager.getTask('task-send-completed-steer')
662
- expect(mocks.run).toHaveBeenCalledTimes(2)
663
- expect(resumedEmit).toHaveBeenCalledWith({
664
- type: 'message',
665
- content: [{
666
- type: 'text',
667
- text: 'queue this for later'
668
- }]
669
- })
670
- expect(task?.logs).toContain('Resuming inactive task (steer): queue this for later')
671
- })
672
-
673
- it('builds synthetic permission recovery for claude-code and resumes after SubmitTaskInput', async () => {
674
- const { TaskManager } = await import('#~/tools/task/manager.js')
675
- const resumedEmit = vi.fn()
676
-
677
- mocks.run
678
- .mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
679
- const session = {
680
- emit: vi.fn(() => {
681
- adapterOptions.onEvent({
682
- type: 'message',
683
- data: {
684
- id: 'assistant-tool-use',
685
- role: 'assistant',
686
- content: [{
687
- type: 'tool_use',
688
- id: 'tool-use-1',
689
- name: 'adapter:claude-code:Write',
690
- input: {
691
- file_path: '/tmp/demo.txt',
692
- content: 'ok'
693
- }
694
- }],
695
- createdAt: Date.now()
696
- }
697
- })
698
- adapterOptions.onEvent({
699
- type: 'error',
700
- data: {
701
- message: 'Permission required to continue',
702
- code: 'permission_required',
703
- details: {
704
- toolUseId: 'tool-use-1',
705
- permissionDenials: [{
706
- message: 'Write requires approval',
707
- deniedTools: []
708
- }]
709
- },
710
- fatal: true
711
- }
712
- })
713
- adapterOptions.onEvent({
714
- type: 'exit',
715
- data: {
716
- exitCode: 1,
717
- stderr: 'permission blocked'
718
- }
719
- })
720
- }),
721
- kill: vi.fn()
722
- }
723
- return {
724
- session,
725
- resolvedAdapter: 'claude-code'
726
- }
727
- })
728
- .mockResolvedValueOnce({
729
- session: {
730
- emit: resumedEmit,
731
- kill: vi.fn()
732
- },
733
- resolvedAdapter: 'claude-code'
734
- })
735
-
736
- const managedTaskManager = new TaskManager()
737
- await managedTaskManager.startTask({
738
- taskId: 'task-claude-recovery',
739
- description: 'trigger',
740
- adapter: 'claude-code'
741
- })
742
-
743
- const waitingTask = managedTaskManager.getTask('task-claude-recovery')
744
- expect(waitingTask?.status).toBe('waiting_input')
745
- expect(waitingTask?.pendingInteraction).toMatchObject({
746
- source: 'permission_recovery',
747
- subjectKeys: ['Write'],
748
- payload: {
749
- question: '当前任务需要使用 Write 才能继续,请选择处理方式。',
750
- kind: 'permission',
751
- permissionContext: expect.objectContaining({
752
- currentMode: undefined,
753
- deniedTools: ['Write'],
754
- subjectKey: 'Write',
755
- subjectLabel: 'Write',
756
- projectConfigPath: '.ai.config.json'
757
- })
758
- }
759
- })
760
-
761
- await managedTaskManager.submitTaskInput({
762
- taskId: 'task-claude-recovery',
763
- data: 'allow_session'
764
- })
765
-
766
- const resumedTask = managedTaskManager.getTask('task-claude-recovery')
767
- expect(resumedTask?.status).toBe('running')
768
- expect(resumedTask?.pendingInteraction).toBeUndefined()
769
- expect(resumedTask?.permissionState).toEqual(expect.objectContaining({
770
- allow: ['Write']
771
- }))
772
- expect(resumedTask?.logs).toContain('Permission decision applied: allow_session. Restarting task.')
773
- expect(mocks.run).toHaveBeenCalledTimes(2)
774
- expect(resumedEmit).toHaveBeenCalledWith({
775
- type: 'message',
776
- content: [{
777
- type: 'text',
778
- text: '权限规则已更新。请继续刚才被权限拦截的工作,并重试被阻止的操作。'
779
- }]
780
- })
781
- expect(mocks.writeFile).toHaveBeenCalled()
782
- })
783
-
784
- it('stops blocked tasks even after the failed session has already exited', async () => {
785
- const { TaskManager } = await import('#~/tools/task/manager.js')
786
-
787
- mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
788
- const session = {
789
- emit: vi.fn(() => {
790
- adapterOptions.onEvent({
791
- type: 'message',
792
- data: {
793
- id: 'assistant-tool-use-stop',
794
- role: 'assistant',
795
- content: [{
796
- type: 'tool_use',
797
- id: 'tool-use-stop-1',
798
- name: 'adapter:claude-code:Write',
799
- input: {
800
- file_path: '/tmp/demo.txt',
801
- content: 'blocked'
802
- }
803
- }],
804
- createdAt: Date.now()
805
- }
806
- })
807
- adapterOptions.onEvent({
808
- type: 'error',
809
- data: {
810
- message: 'Permission required to continue',
811
- code: 'permission_required',
812
- details: {
813
- toolUseId: 'tool-use-stop-1',
814
- permissionDenials: [{
815
- message: 'Write requires approval',
816
- deniedTools: []
817
- }]
818
- },
819
- fatal: true
820
- }
821
- })
822
- adapterOptions.onEvent({
823
- type: 'exit',
824
- data: {
825
- exitCode: 1,
826
- stderr: 'permission blocked'
827
- }
828
- })
829
- }),
830
- kill: vi.fn()
831
- }
832
- return {
833
- session,
834
- resolvedAdapter: 'claude-code'
835
- }
836
- })
837
-
838
- const managedTaskManager = new TaskManager()
839
- await managedTaskManager.startTask({
840
- taskId: 'task-stop-waiting',
841
- description: 'trigger',
842
- adapter: 'claude-code',
843
- enableServerSync: true
844
- })
845
-
846
- const waitingTask = managedTaskManager.getTask('task-stop-waiting')
847
- expect(waitingTask?.status).toBe('waiting_input')
848
- expect(waitingTask?.session).toBeUndefined()
849
- expect(waitingTask?.pendingInteraction).toBeDefined()
850
-
851
- expect(managedTaskManager.stopTask('task-stop-waiting')).toBe(true)
852
- await new Promise(resolve => setTimeout(resolve, 0))
853
-
854
- const stoppedTask = managedTaskManager.getTask('task-stop-waiting')
855
- expect(stoppedTask?.status).toBe('failed')
856
- expect(stoppedTask?.pendingInteraction).toBeUndefined()
857
- expect(stoppedTask?.logs).toContain('Task stopped by user')
858
- expect(mocks.postSessionEvent).toHaveBeenCalledWith('task-stop-waiting', {
859
- type: 'interaction_response',
860
- id: expect.stringContaining('task-recovery:task-stop-waiting:'),
861
- data: 'cancel'
862
- })
863
- expect(mocks.postSessionEvent).toHaveBeenCalledWith('task-stop-waiting', {
864
- type: 'error',
865
- data: {
866
- message: 'Task stopped by user',
867
- fatal: true
868
- }
869
- })
870
- })
871
- })