@vibe-forge/mcp 3.1.3 → 3.3.0-rc.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.
- package/AGENTS.md +4 -9
- package/__tests__/tools.spec.ts +6 -2
- package/package.json +4 -8
- package/src/index.ts +1 -13
- package/src/tools/index.ts +1 -3
- package/src/types.ts +0 -13
- package/__tests__/sync.spec.ts +0 -23
- package/__tests__/task-manager.spec.ts +0 -871
- package/__tests__/task-tool.spec.ts +0 -378
- package/src/sync.ts +0 -70
- package/src/tools/task/index.ts +0 -126
- package/src/tools/task/manager.ts +0 -875
- package/src/tools/task/permission-recovery.ts +0 -172
- package/src/tools/task/permission-state.ts +0 -200
- package/src/tools/task/presentation.ts +0 -125
- package/src/tools/task/register-task-runtime-tools.ts +0 -193
- package/src/tools/task/task-tool-responses.ts +0 -40
|
@@ -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
|
-
})
|