@vibe-forge/mcp 2.0.1 → 2.0.2-alpha.1
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.
|
@@ -208,6 +208,240 @@ describe('taskManager fatal error scenarios', () => {
|
|
|
208
208
|
})
|
|
209
209
|
})
|
|
210
210
|
|
|
211
|
+
it('sends a follow-up message directly to a running task without server sync', async () => {
|
|
212
|
+
const { TaskManager } = await import('#~/tools/task/manager.js')
|
|
213
|
+
const emit = vi.fn()
|
|
214
|
+
|
|
215
|
+
mocks.run.mockResolvedValueOnce({
|
|
216
|
+
session: {
|
|
217
|
+
emit,
|
|
218
|
+
kill: vi.fn()
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const managedTaskManager = new TaskManager()
|
|
223
|
+
await managedTaskManager.startTask({
|
|
224
|
+
taskId: 'task-send-local',
|
|
225
|
+
description: 'trigger'
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
await managedTaskManager.sendTaskMessage({
|
|
229
|
+
taskId: 'task-send-local',
|
|
230
|
+
message: 'keep going'
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const task = managedTaskManager.getTask('task-send-local')
|
|
234
|
+
expect(emit).toHaveBeenNthCalledWith(2, {
|
|
235
|
+
type: 'message',
|
|
236
|
+
content: [{
|
|
237
|
+
type: 'text',
|
|
238
|
+
text: 'keep going'
|
|
239
|
+
}]
|
|
240
|
+
})
|
|
241
|
+
expect(task?.logs).toContain('User message submitted (direct): keep going')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('syncs follow-up messages through the child session when server sync is enabled', async () => {
|
|
245
|
+
const { TaskManager } = await import('#~/tools/task/manager.js')
|
|
246
|
+
const emit = vi.fn()
|
|
247
|
+
|
|
248
|
+
mocks.run.mockResolvedValueOnce({
|
|
249
|
+
session: {
|
|
250
|
+
emit,
|
|
251
|
+
kill: vi.fn()
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
const managedTaskManager = new TaskManager()
|
|
256
|
+
await managedTaskManager.startTask({
|
|
257
|
+
taskId: 'task-send-synced',
|
|
258
|
+
description: 'trigger',
|
|
259
|
+
enableServerSync: true
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
await managedTaskManager.sendTaskMessage({
|
|
263
|
+
taskId: 'task-send-synced',
|
|
264
|
+
message: 'keep going'
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const task = managedTaskManager.getTask('task-send-synced')
|
|
268
|
+
expect(emit).toHaveBeenCalledTimes(1)
|
|
269
|
+
expect(mocks.postSessionEvent).toHaveBeenCalledWith('task-send-synced', {
|
|
270
|
+
type: 'message',
|
|
271
|
+
data: expect.objectContaining({
|
|
272
|
+
role: 'user',
|
|
273
|
+
content: 'keep going'
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
expect(task?.logs).toContain('User message submitted (direct): keep going')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('queues steer follow-up messages and resumes the same task after natural completion', async () => {
|
|
280
|
+
const { TaskManager } = await import('#~/tools/task/manager.js')
|
|
281
|
+
let onEvent: ((event: any) => void) | undefined
|
|
282
|
+
const resumedEmit = vi.fn()
|
|
283
|
+
|
|
284
|
+
mocks.run
|
|
285
|
+
.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
|
|
286
|
+
onEvent = adapterOptions.onEvent
|
|
287
|
+
return {
|
|
288
|
+
session: {
|
|
289
|
+
emit: vi.fn(),
|
|
290
|
+
kill: vi.fn()
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
.mockResolvedValueOnce({
|
|
295
|
+
session: {
|
|
296
|
+
emit: resumedEmit,
|
|
297
|
+
kill: vi.fn()
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
const managedTaskManager = new TaskManager()
|
|
302
|
+
await managedTaskManager.startTask({
|
|
303
|
+
taskId: 'task-send-steer',
|
|
304
|
+
description: 'trigger'
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
await managedTaskManager.sendTaskMessage({
|
|
308
|
+
taskId: 'task-send-steer',
|
|
309
|
+
message: 'after you finish, summarize blockers',
|
|
310
|
+
mode: 'steer'
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
onEvent?.({
|
|
314
|
+
type: 'stop',
|
|
315
|
+
data: undefined
|
|
316
|
+
})
|
|
317
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
318
|
+
|
|
319
|
+
const task = managedTaskManager.getTask('task-send-steer')
|
|
320
|
+
expect(mocks.run).toHaveBeenCalledTimes(2)
|
|
321
|
+
expect(resumedEmit).toHaveBeenCalledWith({
|
|
322
|
+
type: 'message',
|
|
323
|
+
content: [{
|
|
324
|
+
type: 'text',
|
|
325
|
+
text: 'after you finish, summarize blockers'
|
|
326
|
+
}]
|
|
327
|
+
})
|
|
328
|
+
expect(task?.logs).toContain('Queued task message (steer): after you finish, summarize blockers')
|
|
329
|
+
expect(task?.logs).toContain('Resuming task from steer queue: after you finish, summarize blockers')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('rejects follow-up messages when a task is waiting for input', async () => {
|
|
333
|
+
const { TaskManager } = await import('#~/tools/task/manager.js')
|
|
334
|
+
|
|
335
|
+
mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
|
|
336
|
+
const session = {
|
|
337
|
+
emit: vi.fn(() => {
|
|
338
|
+
adapterOptions.onEvent({
|
|
339
|
+
type: 'interaction_request',
|
|
340
|
+
data: {
|
|
341
|
+
id: 'interaction-send-blocked',
|
|
342
|
+
payload: {
|
|
343
|
+
sessionId: 'task-send-blocked',
|
|
344
|
+
kind: 'permission',
|
|
345
|
+
question: 'Allow editing files?',
|
|
346
|
+
options: [
|
|
347
|
+
{ label: 'Allow once', value: 'allow_once' }
|
|
348
|
+
]
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
}),
|
|
353
|
+
kill: vi.fn(),
|
|
354
|
+
respondInteraction: vi.fn()
|
|
355
|
+
}
|
|
356
|
+
return { session }
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
const managedTaskManager = new TaskManager()
|
|
360
|
+
await managedTaskManager.startTask({
|
|
361
|
+
taskId: 'task-send-blocked',
|
|
362
|
+
description: 'trigger',
|
|
363
|
+
background: false
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
await expect(managedTaskManager.sendTaskMessage({
|
|
367
|
+
taskId: 'task-send-blocked',
|
|
368
|
+
message: 'continue'
|
|
369
|
+
})).rejects.toThrow('Task task-send-blocked is waiting for input. Use SubmitTaskInput instead.')
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('rejects steer follow-up messages when a task is waiting for input', async () => {
|
|
373
|
+
const { TaskManager } = await import('#~/tools/task/manager.js')
|
|
374
|
+
|
|
375
|
+
mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
|
|
376
|
+
const session = {
|
|
377
|
+
emit: vi.fn(() => {
|
|
378
|
+
adapterOptions.onEvent({
|
|
379
|
+
type: 'interaction_request',
|
|
380
|
+
data: {
|
|
381
|
+
id: 'interaction-send-steer-blocked',
|
|
382
|
+
payload: {
|
|
383
|
+
sessionId: 'task-send-steer-blocked',
|
|
384
|
+
kind: 'permission',
|
|
385
|
+
question: 'Allow editing files?',
|
|
386
|
+
options: [
|
|
387
|
+
{ label: 'Allow once', value: 'allow_once' }
|
|
388
|
+
]
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
}),
|
|
393
|
+
kill: vi.fn(),
|
|
394
|
+
respondInteraction: vi.fn()
|
|
395
|
+
}
|
|
396
|
+
return { session }
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
const managedTaskManager = new TaskManager()
|
|
400
|
+
await managedTaskManager.startTask({
|
|
401
|
+
taskId: 'task-send-steer-blocked',
|
|
402
|
+
description: 'trigger',
|
|
403
|
+
background: false
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
await expect(managedTaskManager.sendTaskMessage({
|
|
407
|
+
taskId: 'task-send-steer-blocked',
|
|
408
|
+
message: 'summarize blockers after this',
|
|
409
|
+
mode: 'steer'
|
|
410
|
+
})).rejects.toThrow('Task task-send-steer-blocked is waiting for input. Use SubmitTaskInput instead.')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('rejects steer follow-up messages after a task has already completed', async () => {
|
|
414
|
+
const { TaskManager } = await import('#~/tools/task/manager.js')
|
|
415
|
+
let onEvent: ((event: any) => void) | undefined
|
|
416
|
+
|
|
417
|
+
mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
|
|
418
|
+
onEvent = adapterOptions.onEvent
|
|
419
|
+
return {
|
|
420
|
+
session: {
|
|
421
|
+
emit: vi.fn(),
|
|
422
|
+
kill: vi.fn()
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
const managedTaskManager = new TaskManager()
|
|
428
|
+
await managedTaskManager.startTask({
|
|
429
|
+
taskId: 'task-send-completed',
|
|
430
|
+
description: 'trigger'
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
onEvent?.({
|
|
434
|
+
type: 'stop',
|
|
435
|
+
data: undefined
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
await expect(managedTaskManager.sendTaskMessage({
|
|
439
|
+
taskId: 'task-send-completed',
|
|
440
|
+
message: 'queue this for later',
|
|
441
|
+
mode: 'steer'
|
|
442
|
+
})).rejects.toThrow('Task task-send-completed is not active. Start a new task instead.')
|
|
443
|
+
})
|
|
444
|
+
|
|
211
445
|
it('builds synthetic permission recovery for claude-code and resumes after SubmitTaskInput', async () => {
|
|
212
446
|
const { TaskManager } = await import('#~/tools/task/manager.js')
|
|
213
447
|
const resumedEmit = vi.fn()
|
|
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => {
|
|
|
8
8
|
createChildSession: vi.fn(),
|
|
9
9
|
getParentSessionId: vi.fn(),
|
|
10
10
|
startTask: vi.fn(),
|
|
11
|
+
sendTaskMessage: vi.fn(),
|
|
11
12
|
submitTaskInput: vi.fn(),
|
|
12
13
|
respondToTaskInteraction: vi.fn(),
|
|
13
14
|
getTask: vi.fn(),
|
|
@@ -33,6 +34,7 @@ vi.mock('#~/sync.js', () => ({
|
|
|
33
34
|
vi.mock('#~/tools/task/manager.js', () => ({
|
|
34
35
|
TaskManager: class {
|
|
35
36
|
startTask = mocks.startTask
|
|
37
|
+
sendTaskMessage = mocks.sendTaskMessage
|
|
36
38
|
submitTaskInput = mocks.submitTaskInput
|
|
37
39
|
respondToTaskInteraction = mocks.respondToTaskInteraction
|
|
38
40
|
getTask = mocks.getTask
|
|
@@ -52,6 +54,7 @@ describe('task tool integration', () => {
|
|
|
52
54
|
mocks.getParentSessionId.mockReturnValue(undefined)
|
|
53
55
|
mocks.createChildSession.mockResolvedValue({})
|
|
54
56
|
mocks.startTask.mockResolvedValue(undefined)
|
|
57
|
+
mocks.sendTaskMessage.mockResolvedValue(undefined)
|
|
55
58
|
mocks.submitTaskInput.mockResolvedValue(undefined)
|
|
56
59
|
mocks.respondToTaskInteraction.mockResolvedValue(undefined)
|
|
57
60
|
mocks.getTask.mockImplementation((taskId: string) => ({
|
|
@@ -181,15 +184,96 @@ describe('task tool integration', () => {
|
|
|
181
184
|
const tester = createToolTester()
|
|
182
185
|
createTaskRegister()(tester.mockRegister)
|
|
183
186
|
|
|
187
|
+
expect(tester.getRegisteredTools()).toContain('SendTaskMessage')
|
|
184
188
|
expect(tester.getRegisteredTools()).toContain('SubmitTaskInput')
|
|
185
189
|
expect(tester.getRegisteredTools()).toContain('RespondTaskInteraction')
|
|
186
190
|
expect(tester.getToolInfo('StartTasks')?.description).toContain('GetTaskInfo')
|
|
187
|
-
expect(tester.getToolInfo('
|
|
191
|
+
expect(tester.getToolInfo('StartTasks')?.description).toContain('SendTaskMessage')
|
|
192
|
+
expect(tester.getToolInfo('GetTaskInfo')?.description).toContain('10 most recent logs')
|
|
193
|
+
expect(tester.getToolInfo('GetTaskInfo')?.description).toContain('logOrder')
|
|
194
|
+
expect(tester.getToolInfo('GetTaskInfo')?.description).toContain('SendTaskMessage')
|
|
195
|
+
expect(tester.getToolInfo('SendTaskMessage')?.description).toContain('mode "direct"')
|
|
196
|
+
expect(tester.getToolInfo('SendTaskMessage')?.description).toContain('mode "steer"')
|
|
197
|
+
expect(tester.getToolInfo('ListTasks')?.description).toContain('10 most recent logs')
|
|
198
|
+
expect(tester.getToolInfo('ListTasks')?.description).toContain('SendTaskMessage')
|
|
188
199
|
expect(tester.getToolInfo('ListTasks')?.description).toContain('pendingInput')
|
|
200
|
+
expect(tester.getToolInfo('SubmitTaskInput')?.description).toContain('SendTaskMessage')
|
|
189
201
|
expect(tester.getToolInfo('SubmitTaskInput')?.description).toContain('allow_once')
|
|
190
202
|
expect(tester.getToolInfo('RespondTaskInteraction')?.description).toContain('Deprecated alias')
|
|
191
203
|
})
|
|
192
204
|
|
|
205
|
+
it('returns the 10 most recent logs in descending order by default', async () => {
|
|
206
|
+
mocks.getTask.mockReturnValue({
|
|
207
|
+
taskId: 'task-1',
|
|
208
|
+
status: 'running',
|
|
209
|
+
logs: Array.from({ length: 12 }, (_, index) => `log-${index + 1}`)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const { createTaskRegister } = await import('#~/tools/task/index.js')
|
|
213
|
+
|
|
214
|
+
const tester = createToolTester()
|
|
215
|
+
createTaskRegister()(tester.mockRegister)
|
|
216
|
+
|
|
217
|
+
const result = await tester.callTool('GetTaskInfo', {
|
|
218
|
+
taskId: 'task-1'
|
|
219
|
+
}) as { content: Array<{ text: string }> }
|
|
220
|
+
const [task] = JSON.parse(result.content[0].text) as Array<{ logs: string[] }>
|
|
221
|
+
|
|
222
|
+
expect(task.logs).toEqual([
|
|
223
|
+
'log-12',
|
|
224
|
+
'log-11',
|
|
225
|
+
'log-10',
|
|
226
|
+
'log-9',
|
|
227
|
+
'log-8',
|
|
228
|
+
'log-7',
|
|
229
|
+
'log-6',
|
|
230
|
+
'log-5',
|
|
231
|
+
'log-4',
|
|
232
|
+
'log-3'
|
|
233
|
+
])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('supports custom log windows and ascending order in ListTasks', async () => {
|
|
237
|
+
mocks.getAllTasks.mockReturnValue([
|
|
238
|
+
{
|
|
239
|
+
taskId: 'task-1',
|
|
240
|
+
status: 'running',
|
|
241
|
+
logs: ['a', 'b', 'c', 'd']
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
taskId: 'task-2',
|
|
245
|
+
status: 'completed',
|
|
246
|
+
logs: ['1', '2', '3']
|
|
247
|
+
}
|
|
248
|
+
])
|
|
249
|
+
|
|
250
|
+
const { createTaskRegister } = await import('#~/tools/task/index.js')
|
|
251
|
+
|
|
252
|
+
const tester = createToolTester()
|
|
253
|
+
createTaskRegister()(tester.mockRegister)
|
|
254
|
+
|
|
255
|
+
const result = await tester.callTool('ListTasks', {
|
|
256
|
+
logLimit: 2,
|
|
257
|
+
logOrder: 'asc'
|
|
258
|
+
}) as { content: Array<{ text: string }> }
|
|
259
|
+
const tasks = JSON.parse(result.content[0].text) as Array<{ taskId: string; logs: string[] }>
|
|
260
|
+
|
|
261
|
+
expect(tasks).toEqual([
|
|
262
|
+
{
|
|
263
|
+
taskId: 'task-1',
|
|
264
|
+
status: 'running',
|
|
265
|
+
logs: ['c', 'd'],
|
|
266
|
+
guidance: []
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
taskId: 'task-2',
|
|
270
|
+
status: 'completed',
|
|
271
|
+
logs: ['2', '3'],
|
|
272
|
+
guidance: []
|
|
273
|
+
}
|
|
274
|
+
])
|
|
275
|
+
})
|
|
276
|
+
|
|
193
277
|
it('forwards SubmitTaskInput to the task manager', async () => {
|
|
194
278
|
mocks.getTask.mockReturnValue({
|
|
195
279
|
taskId: 'task-1',
|
|
@@ -214,6 +298,31 @@ describe('task tool integration', () => {
|
|
|
214
298
|
})
|
|
215
299
|
})
|
|
216
300
|
|
|
301
|
+
it('forwards SendTaskMessage to the task manager', async () => {
|
|
302
|
+
mocks.getTask.mockReturnValue({
|
|
303
|
+
taskId: 'task-1',
|
|
304
|
+
status: 'running',
|
|
305
|
+
logs: ['Queued task message (steer): keep checking logs']
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
const { createTaskRegister } = await import('#~/tools/task/index.js')
|
|
309
|
+
|
|
310
|
+
const tester = createToolTester()
|
|
311
|
+
createTaskRegister()(tester.mockRegister)
|
|
312
|
+
|
|
313
|
+
await tester.callTool('SendTaskMessage', {
|
|
314
|
+
taskId: 'task-1',
|
|
315
|
+
message: 'keep checking logs',
|
|
316
|
+
mode: 'steer'
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
expect(mocks.sendTaskMessage).toHaveBeenCalledWith({
|
|
320
|
+
taskId: 'task-1',
|
|
321
|
+
message: 'keep checking logs',
|
|
322
|
+
mode: 'steer'
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
217
326
|
it('keeps RespondTaskInteraction as a deprecated alias', async () => {
|
|
218
327
|
mocks.getTask.mockReturnValue({
|
|
219
328
|
taskId: 'task-1',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibe-forge/mcp",
|
|
3
|
-
"version": "2.0.1",
|
|
3
|
+
"version": "2.0.2-alpha.1",
|
|
4
4
|
"description": "Vibe Forge MCP server",
|
|
5
5
|
"imports": {
|
|
6
6
|
"#~/*.js": {
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"@vibe-forge/config": "^2.0.2",
|
|
35
35
|
"commander": "^12.1.0",
|
|
36
36
|
"zod": "^3.24.1",
|
|
37
|
-
"@vibe-forge/hooks": "
|
|
38
|
-
"@vibe-forge/
|
|
39
|
-
"@vibe-forge/
|
|
40
|
-
"@vibe-forge/
|
|
41
|
-
"@vibe-forge/types": "^2.0.
|
|
42
|
-
"@vibe-forge/
|
|
37
|
+
"@vibe-forge/hooks": "2.0.2-alpha.0",
|
|
38
|
+
"@vibe-forge/cli-helper": "^2.0.0",
|
|
39
|
+
"@vibe-forge/task": "2.0.1-alpha.3",
|
|
40
|
+
"@vibe-forge/register": "^2.0.0",
|
|
41
|
+
"@vibe-forge/types": "^2.0.2",
|
|
42
|
+
"@vibe-forge/utils": "2.0.4-alpha.1"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"test": "pnpm -C ../.. exec vitest run --workspace vitest.workspace.ts --project bundler packages/mcp/__tests__"
|
|
@@ -61,6 +61,7 @@ export interface TaskInfo {
|
|
|
61
61
|
exitCode?: number
|
|
62
62
|
logs: string[]
|
|
63
63
|
permissionState: SessionPermissionState
|
|
64
|
+
queuedSteerMessages: string[]
|
|
64
65
|
pendingInteraction?: PendingTaskInteraction
|
|
65
66
|
lastError?: AdapterErrorData
|
|
66
67
|
session?: ManagedTaskSession
|
|
@@ -252,6 +253,7 @@ export class TaskManager {
|
|
|
252
253
|
status: 'running',
|
|
253
254
|
logs: [],
|
|
254
255
|
permissionState: createEmptySessionPermissionState(),
|
|
256
|
+
queuedSteerMessages: [],
|
|
255
257
|
createdAt: Date.now()
|
|
256
258
|
}
|
|
257
259
|
if (enableServerSync) {
|
|
@@ -285,7 +287,7 @@ export class TaskManager {
|
|
|
285
287
|
return { taskId }
|
|
286
288
|
}
|
|
287
289
|
|
|
288
|
-
private async launchTask(task: TaskInfo, runType: 'create' | 'resume') {
|
|
290
|
+
private async launchTask(task: TaskInfo, runType: 'create' | 'resume', resumeMessage?: string) {
|
|
289
291
|
try {
|
|
290
292
|
const rootCwd = process.cwd()
|
|
291
293
|
const promptType = task.type !== 'default' ? task.type : undefined
|
|
@@ -374,7 +376,7 @@ export class TaskManager {
|
|
|
374
376
|
type: 'message',
|
|
375
377
|
content: [{
|
|
376
378
|
type: 'text',
|
|
377
|
-
text: runType === 'resume' ? TASK_PERMISSION_CONTINUE_PROMPT : current.description
|
|
379
|
+
text: resumeMessage ?? (runType === 'resume' ? TASK_PERMISSION_CONTINUE_PROMPT : current.description)
|
|
378
380
|
}]
|
|
379
381
|
})
|
|
380
382
|
} catch (err) {
|
|
@@ -467,6 +469,17 @@ export class TaskManager {
|
|
|
467
469
|
task.status = 'completed'
|
|
468
470
|
task.pendingInteraction = undefined
|
|
469
471
|
this.stopServerPolling(taskId)
|
|
472
|
+
if (task.queuedSteerMessages.length > 0) {
|
|
473
|
+
void this.dispatchQueuedSteerMessage(task).catch((error) => {
|
|
474
|
+
task.status = 'failed'
|
|
475
|
+
appendTaskLog(
|
|
476
|
+
task,
|
|
477
|
+
`Failed to resume steer-queued task: ${error instanceof Error ? error.message : String(error)}`
|
|
478
|
+
)
|
|
479
|
+
task.onStop?.()
|
|
480
|
+
})
|
|
481
|
+
break
|
|
482
|
+
}
|
|
470
483
|
task.onStop?.()
|
|
471
484
|
break
|
|
472
485
|
}
|
|
@@ -489,6 +502,17 @@ export class TaskManager {
|
|
|
489
502
|
task.pendingInteraction = undefined
|
|
490
503
|
appendTaskLog(task, `Process exited with code ${event.data.exitCode}`)
|
|
491
504
|
this.stopServerPolling(taskId)
|
|
505
|
+
if (task.status === 'completed' && task.queuedSteerMessages.length > 0) {
|
|
506
|
+
void this.dispatchQueuedSteerMessage(task).catch((error) => {
|
|
507
|
+
task.status = 'failed'
|
|
508
|
+
appendTaskLog(
|
|
509
|
+
task,
|
|
510
|
+
`Failed to resume steer-queued task: ${error instanceof Error ? error.message : String(error)}`
|
|
511
|
+
)
|
|
512
|
+
task.onStop?.()
|
|
513
|
+
})
|
|
514
|
+
break
|
|
515
|
+
}
|
|
492
516
|
task.onStop?.()
|
|
493
517
|
break
|
|
494
518
|
default:
|
|
@@ -581,6 +605,74 @@ export class TaskManager {
|
|
|
581
605
|
return Array.from(this.tasks.values())
|
|
582
606
|
}
|
|
583
607
|
|
|
608
|
+
public async sendTaskMessage(params: {
|
|
609
|
+
taskId: string
|
|
610
|
+
message: string
|
|
611
|
+
mode?: 'direct' | 'steer'
|
|
612
|
+
}): Promise<void> {
|
|
613
|
+
const task = this.tasks.get(params.taskId)
|
|
614
|
+
if (task == null) {
|
|
615
|
+
throw new Error(`Task ${params.taskId} not found.`)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const message = params.message.trim()
|
|
619
|
+
const mode = params.mode ?? 'direct'
|
|
620
|
+
if (message === '') {
|
|
621
|
+
throw new Error('Task message cannot be empty.')
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (task.pendingInteraction != null || task.status === 'waiting_input') {
|
|
625
|
+
throw new Error(`Task ${params.taskId} is waiting for input. Use SubmitTaskInput instead.`)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (mode === 'steer') {
|
|
629
|
+
if (task.status === 'completed' || task.status === 'failed') {
|
|
630
|
+
throw new Error(`Task ${params.taskId} is not active. Start a new task instead.`)
|
|
631
|
+
}
|
|
632
|
+
task.queuedSteerMessages.push(message)
|
|
633
|
+
appendTaskLog(task, `Queued task message (${mode}): ${message}`)
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (task.status !== 'running' || task.session == null) {
|
|
638
|
+
throw new Error(`Task ${params.taskId} is not running. Start a new task instead.`)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (task.serverSync != null) {
|
|
642
|
+
await postSessionEvent(task.serverSync.sessionId, {
|
|
643
|
+
type: 'message',
|
|
644
|
+
data: {
|
|
645
|
+
id: `task-user:${task.taskId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`,
|
|
646
|
+
role: 'user',
|
|
647
|
+
content: message,
|
|
648
|
+
createdAt: Date.now()
|
|
649
|
+
}
|
|
650
|
+
})
|
|
651
|
+
} else {
|
|
652
|
+
task.session.emit({
|
|
653
|
+
type: 'message',
|
|
654
|
+
content: [{
|
|
655
|
+
type: 'text',
|
|
656
|
+
text: message
|
|
657
|
+
}]
|
|
658
|
+
})
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
appendTaskLog(task, `User message submitted (${mode}): ${message}`)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private async dispatchQueuedSteerMessage(task: TaskInfo) {
|
|
665
|
+
const nextMessage = task.queuedSteerMessages.shift()
|
|
666
|
+
if (nextMessage == null || nextMessage.trim() === '') {
|
|
667
|
+
return false
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
task.status = 'running'
|
|
671
|
+
appendTaskLog(task, `Resuming task from steer queue: ${nextMessage}`)
|
|
672
|
+
await this.launchTask(task, 'resume', nextMessage)
|
|
673
|
+
return true
|
|
674
|
+
}
|
|
675
|
+
|
|
584
676
|
public async submitTaskInput(params: {
|
|
585
677
|
taskId: string
|
|
586
678
|
interactionId?: string
|
|
@@ -5,15 +5,21 @@ import type { SessionPermissionMode } from '@vibe-forge/types'
|
|
|
5
5
|
import type { TaskInfo } from './manager'
|
|
6
6
|
|
|
7
7
|
export const SESSION_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'] as const
|
|
8
|
+
export const TASK_LOG_ORDERS = ['asc', 'desc'] as const
|
|
9
|
+
export const DEFAULT_TASK_LOG_LIMIT = 10
|
|
10
|
+
export type TaskLogsOrder = typeof TASK_LOG_ORDERS[number]
|
|
8
11
|
|
|
9
12
|
export const START_TASKS_DESCRIPTION =
|
|
10
|
-
'Start multiple tasks in background or foreground. Use type "workspace" plus name to run in a configured workspace. If a task stalls, fails, or asks for permission/input, call GetTaskInfo. If GetTaskInfo returns pendingInput or pendingInteraction, resolve it with SubmitTaskInput. If logs show permission_required, you can answer the recovery prompt with SubmitTaskInput instead of restarting manually.'
|
|
13
|
+
'Start multiple tasks in background or foreground. Use type "workspace" plus name to run in a configured workspace. If a task stalls, fails, or asks for permission/input, call GetTaskInfo. GetTaskInfo returns the 10 most recent logs by default in descending order, so newer log lines appear earlier in the logs array. If you need to add another instruction to a task that is still running, use SendTaskMessage instead of starting a replacement task. If GetTaskInfo returns pendingInput or pendingInteraction, resolve it with SubmitTaskInput. If logs show permission_required, you can answer the recovery prompt with SubmitTaskInput instead of restarting manually.'
|
|
11
14
|
|
|
12
15
|
export const GET_TASK_INFO_DESCRIPTION =
|
|
13
|
-
'Get the detailed status, logs, pendingInput, pendingInteraction, lastError, and guidance for a task.
|
|
16
|
+
'Get the detailed status, logs, pendingInput, pendingInteraction, lastError, and guidance for a task. By default this returns the 10 most recent logs in descending order, so newer log lines appear earlier in the logs array. Use logLimit to inspect a different number of recent logs, and set logOrder to "asc" when you want the selected log window in oldest-to-newest order. If the task is still active and you need to continue it, call SendTaskMessage with mode "direct" or "steer" depending on timing. If pendingInput is present, answer it with SubmitTaskInput.'
|
|
17
|
+
|
|
18
|
+
export const SEND_TASK_MESSAGE_DESCRIPTION =
|
|
19
|
+
'Send a follow-up user message to a managed task. Use mode "direct" (default) to continue the current running task immediately. Use mode "steer" to queue a follow-up that should run after the current task finishes naturally. Do not use this to answer pendingInput or pendingInteraction; use SubmitTaskInput for that. If the task already completed or failed, start a new task instead.'
|
|
14
20
|
|
|
15
21
|
export const SUBMIT_TASK_INPUT_DESCRIPTION =
|
|
16
|
-
'Submit input for a task that is blocked waiting for permission or user input. First call GetTaskInfo or ListTasks, then use taskId plus one of pendingInput.payload.options[].value when available. Common permission answers are allow_once, allow_session, allow_project, deny_once, deny_session, or deny_project.'
|
|
22
|
+
'Submit input for a task that is blocked waiting for permission or user input. First call GetTaskInfo or ListTasks, then use taskId plus one of pendingInput.payload.options[].value when available. Do not use this for ordinary follow-up instructions to a running task or queued steer messages; use SendTaskMessage instead. Common permission answers are allow_once, allow_session, allow_project, deny_once, deny_session, or deny_project.'
|
|
17
23
|
|
|
18
24
|
export const RESPOND_TASK_INTERACTION_DESCRIPTION =
|
|
19
25
|
'Deprecated alias of SubmitTaskInput. Use SubmitTaskInput for both permission prompts and generic task input.'
|
|
@@ -22,7 +28,7 @@ export const STOP_TASK_DESCRIPTION =
|
|
|
22
28
|
'Stop a running or blocked task. Use this when the task is no longer needed or cannot recover cleanly.'
|
|
23
29
|
|
|
24
30
|
export const LIST_TASKS_DESCRIPTION =
|
|
25
|
-
'List all managed tasks with status, pendingInput, pendingInteraction, lastError, and guidance. Use
|
|
31
|
+
'List all managed tasks with status, logs, pendingInput, pendingInteraction, lastError, and guidance. Each task returns the 10 most recent logs by default in descending order, so newer log lines appear earlier in the logs array. Use logLimit and logOrder to adjust the recent log window for every listed task. If a listed task needs another instruction, call SendTaskMessage with mode "direct" or "steer". If it is waiting for input, call GetTaskInfo for details or SubmitTaskInput to answer it.'
|
|
26
32
|
|
|
27
33
|
export const TASK_PERMISSION_MODE_DESCRIPTION =
|
|
28
34
|
'Permission mode for the task. If omitted, inherits the parent session. Raise it only when the task is blocked by permission errors.'
|
|
@@ -30,6 +36,12 @@ export const TASK_PERMISSION_MODE_DESCRIPTION =
|
|
|
30
36
|
export const TASK_BACKGROUND_DESCRIPTION =
|
|
31
37
|
'Whether to run in background (default: true). If false, waits until the task completes, fails, or becomes blocked waiting for input, then returns the current logs.'
|
|
32
38
|
|
|
39
|
+
export const TASK_LOG_LIMIT_DESCRIPTION =
|
|
40
|
+
`How many recent log entries to include. Defaults to ${DEFAULT_TASK_LOG_LIMIT}.`
|
|
41
|
+
|
|
42
|
+
export const TASK_LOG_ORDER_DESCRIPTION =
|
|
43
|
+
'Order of the selected log window. Defaults to "desc", which returns newer log lines first. Use "asc" for oldest-to-newest order.'
|
|
44
|
+
|
|
33
45
|
export const resolveInheritedPermissionMode = (): SessionPermissionMode | undefined => {
|
|
34
46
|
const value = process.env.__VF_PROJECT_AI_PERMISSION_MODE__?.trim()
|
|
35
47
|
if (value == null || value === '') return undefined
|
|
@@ -74,20 +86,33 @@ export const serializeTaskInfo = (params: {
|
|
|
74
86
|
description?: string
|
|
75
87
|
status?: TaskInfo['status']
|
|
76
88
|
info?: TaskInfo
|
|
89
|
+
logLimit?: number
|
|
90
|
+
logOrder?: TaskLogsOrder
|
|
77
91
|
}) => {
|
|
78
92
|
const info = params.info
|
|
79
93
|
const safeInfo = (() => {
|
|
80
94
|
if (info == null) {
|
|
81
95
|
return undefined
|
|
82
96
|
}
|
|
83
|
-
const { session, onStop, serverSync, createdAt, ...rest } = info
|
|
97
|
+
const { session, onStop, serverSync, createdAt, logs, ...rest } = info
|
|
84
98
|
return rest
|
|
85
99
|
})()
|
|
100
|
+
const selectedLogs = (() => {
|
|
101
|
+
const logs = info?.logs ?? []
|
|
102
|
+
const limit = params.logLimit
|
|
103
|
+
const windowedLogs = limit == null
|
|
104
|
+
? logs
|
|
105
|
+
: logs.slice(Math.max(0, logs.length - limit))
|
|
106
|
+
return params.logOrder === 'desc'
|
|
107
|
+
? [...windowedLogs].reverse()
|
|
108
|
+
: windowedLogs
|
|
109
|
+
})()
|
|
110
|
+
|
|
86
111
|
return {
|
|
87
112
|
taskId: params.taskId,
|
|
88
113
|
description: params.description ?? info?.description,
|
|
89
114
|
status: info?.status ?? params.status,
|
|
90
|
-
logs:
|
|
115
|
+
logs: selectedLogs,
|
|
91
116
|
pendingInput: safeInfo?.pendingInteraction,
|
|
92
117
|
...safeInfo,
|
|
93
118
|
guidance: buildTaskGuidance(safeInfo ?? {})
|
|
@@ -3,11 +3,16 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { Register } from '../types'
|
|
4
4
|
import type { TaskManager } from './manager'
|
|
5
5
|
import {
|
|
6
|
+
DEFAULT_TASK_LOG_LIMIT,
|
|
6
7
|
GET_TASK_INFO_DESCRIPTION,
|
|
7
8
|
LIST_TASKS_DESCRIPTION,
|
|
8
9
|
RESPOND_TASK_INTERACTION_DESCRIPTION,
|
|
10
|
+
SEND_TASK_MESSAGE_DESCRIPTION,
|
|
9
11
|
STOP_TASK_DESCRIPTION,
|
|
10
12
|
SUBMIT_TASK_INPUT_DESCRIPTION,
|
|
13
|
+
TASK_LOG_LIMIT_DESCRIPTION,
|
|
14
|
+
TASK_LOG_ORDER_DESCRIPTION,
|
|
15
|
+
TASK_LOG_ORDERS,
|
|
11
16
|
serializeTaskInfo
|
|
12
17
|
} from './presentation'
|
|
13
18
|
|
|
@@ -21,10 +26,20 @@ export const registerTaskRuntimeTools = (
|
|
|
21
26
|
title: 'Get Task Info',
|
|
22
27
|
description: GET_TASK_INFO_DESCRIPTION,
|
|
23
28
|
inputSchema: z.object({
|
|
24
|
-
taskId: z.string().describe('The ID of the task to check')
|
|
29
|
+
taskId: z.string().describe('The ID of the task to check'),
|
|
30
|
+
logLimit: z
|
|
31
|
+
.number()
|
|
32
|
+
.int()
|
|
33
|
+
.min(1)
|
|
34
|
+
.describe(TASK_LOG_LIMIT_DESCRIPTION)
|
|
35
|
+
.default(DEFAULT_TASK_LOG_LIMIT),
|
|
36
|
+
logOrder: z
|
|
37
|
+
.enum(TASK_LOG_ORDERS)
|
|
38
|
+
.describe(TASK_LOG_ORDER_DESCRIPTION)
|
|
39
|
+
.default('desc')
|
|
25
40
|
})
|
|
26
41
|
},
|
|
27
|
-
async ({ taskId }) => {
|
|
42
|
+
async ({ taskId, logLimit, logOrder }) => {
|
|
28
43
|
const task = taskManager.getTask(taskId)
|
|
29
44
|
if (!task) {
|
|
30
45
|
return {
|
|
@@ -35,7 +50,46 @@ export const registerTaskRuntimeTools = (
|
|
|
35
50
|
return {
|
|
36
51
|
content: [{
|
|
37
52
|
type: 'text',
|
|
38
|
-
text: JSON.stringify([serializeTaskInfo({ taskId, info: task })])
|
|
53
|
+
text: JSON.stringify([serializeTaskInfo({ taskId, info: task, logLimit, logOrder })])
|
|
54
|
+
}]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
server.registerTool(
|
|
60
|
+
'SendTaskMessage',
|
|
61
|
+
{
|
|
62
|
+
title: 'Send Task Message',
|
|
63
|
+
description: SEND_TASK_MESSAGE_DESCRIPTION,
|
|
64
|
+
inputSchema: z.object({
|
|
65
|
+
taskId: z.string().describe('The ID of the running task to continue'),
|
|
66
|
+
message: z
|
|
67
|
+
.string()
|
|
68
|
+
.trim()
|
|
69
|
+
.min(1)
|
|
70
|
+
.describe('The follow-up instruction to send to the task'),
|
|
71
|
+
mode: z
|
|
72
|
+
.enum(['direct', 'steer'])
|
|
73
|
+
.describe('How to deliver the message: direct (default) or steer')
|
|
74
|
+
.default('direct')
|
|
75
|
+
})
|
|
76
|
+
},
|
|
77
|
+
async ({ taskId, message, mode }) => {
|
|
78
|
+
await taskManager.sendTaskMessage({
|
|
79
|
+
taskId,
|
|
80
|
+
message,
|
|
81
|
+
mode
|
|
82
|
+
})
|
|
83
|
+
const task = taskManager.getTask(taskId)
|
|
84
|
+
return {
|
|
85
|
+
content: [{
|
|
86
|
+
type: 'text',
|
|
87
|
+
text: JSON.stringify([serializeTaskInfo({
|
|
88
|
+
taskId,
|
|
89
|
+
info: task,
|
|
90
|
+
logLimit: DEFAULT_TASK_LOG_LIMIT,
|
|
91
|
+
logOrder: 'desc'
|
|
92
|
+
})])
|
|
39
93
|
}]
|
|
40
94
|
}
|
|
41
95
|
}
|
|
@@ -67,7 +121,12 @@ export const registerTaskRuntimeTools = (
|
|
|
67
121
|
return {
|
|
68
122
|
content: [{
|
|
69
123
|
type: 'text',
|
|
70
|
-
text: JSON.stringify([serializeTaskInfo({
|
|
124
|
+
text: JSON.stringify([serializeTaskInfo({
|
|
125
|
+
taskId,
|
|
126
|
+
info: task,
|
|
127
|
+
logLimit: DEFAULT_TASK_LOG_LIMIT,
|
|
128
|
+
logOrder: 'desc'
|
|
129
|
+
})])
|
|
71
130
|
}]
|
|
72
131
|
}
|
|
73
132
|
}
|
|
@@ -99,7 +158,12 @@ export const registerTaskRuntimeTools = (
|
|
|
99
158
|
return {
|
|
100
159
|
content: [{
|
|
101
160
|
type: 'text',
|
|
102
|
-
text: JSON.stringify([serializeTaskInfo({
|
|
161
|
+
text: JSON.stringify([serializeTaskInfo({
|
|
162
|
+
taskId,
|
|
163
|
+
info: task,
|
|
164
|
+
logLimit: DEFAULT_TASK_LOG_LIMIT,
|
|
165
|
+
logOrder: 'desc'
|
|
166
|
+
})])
|
|
103
167
|
}]
|
|
104
168
|
}
|
|
105
169
|
}
|
|
@@ -130,14 +194,30 @@ export const registerTaskRuntimeTools = (
|
|
|
130
194
|
{
|
|
131
195
|
title: 'List Tasks',
|
|
132
196
|
description: LIST_TASKS_DESCRIPTION,
|
|
133
|
-
inputSchema: z.object({
|
|
197
|
+
inputSchema: z.object({
|
|
198
|
+
logLimit: z
|
|
199
|
+
.number()
|
|
200
|
+
.int()
|
|
201
|
+
.min(1)
|
|
202
|
+
.describe(TASK_LOG_LIMIT_DESCRIPTION)
|
|
203
|
+
.default(DEFAULT_TASK_LOG_LIMIT),
|
|
204
|
+
logOrder: z
|
|
205
|
+
.enum(TASK_LOG_ORDERS)
|
|
206
|
+
.describe(TASK_LOG_ORDER_DESCRIPTION)
|
|
207
|
+
.default('desc')
|
|
208
|
+
})
|
|
134
209
|
},
|
|
135
|
-
async () => {
|
|
210
|
+
async ({ logLimit, logOrder }) => {
|
|
136
211
|
const tasks = taskManager.getAllTasks()
|
|
137
212
|
return {
|
|
138
213
|
content: [{
|
|
139
214
|
type: 'text',
|
|
140
|
-
text: JSON.stringify(tasks.map(task => serializeTaskInfo({
|
|
215
|
+
text: JSON.stringify(tasks.map(task => serializeTaskInfo({
|
|
216
|
+
taskId: task.taskId,
|
|
217
|
+
info: task,
|
|
218
|
+
logLimit,
|
|
219
|
+
logOrder
|
|
220
|
+
})))
|
|
141
221
|
}]
|
|
142
222
|
}
|
|
143
223
|
}
|