refactor: update package.json scripts and enhance CI workflow
- Renamed the 'check' script to 'check:workspace' for clarity and updated the main 'check' script to include linting. - Modified CI workflow to monitor changes in the '.github/workflows/**' and 'pnpm-workspace.yaml' files, ensuring better integration and tracking. - Added new tests to validate task lifecycle and review processes, enhancing overall test coverage and reliability.
This commit is contained in:
parent
48e5d9d6cd
commit
d486510c9e
13 changed files with 507 additions and 52 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -8,6 +8,8 @@ on:
|
|||
- 'agent-teams-controller/**'
|
||||
- 'mcp-server/**'
|
||||
- 'test/**'
|
||||
- '.github/workflows/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'tsconfig*.json'
|
||||
|
|
@ -22,6 +24,8 @@ on:
|
|||
- 'agent-teams-controller/**'
|
||||
- 'mcp-server/**'
|
||||
- 'test/**'
|
||||
- '.github/workflows/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'tsconfig*.json'
|
||||
|
|
|
|||
|
|
@ -193,4 +193,157 @@ describe('agent-teams-controller API', () => {
|
|||
expect(second.staleColumnOrderRefsRemoved).toBe(0);
|
||||
expect(second.linkedCommentsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('derives reviewState from legacy kanban overlay and tolerates corrupt kanban state', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Legacy review task' });
|
||||
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
|
||||
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
||||
delete rawTask.reviewState;
|
||||
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
|
||||
|
||||
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
||||
fs.writeFileSync(
|
||||
kanbanPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
teamName: 'my-team',
|
||||
reviewers: [],
|
||||
tasks: {
|
||||
[task.id]: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z', reviewer: null },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('review');
|
||||
expect(controller.tasks.listTasks()[0].reviewState).toBe('review');
|
||||
|
||||
fs.writeFileSync(kanbanPath, '{broken-json');
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('none');
|
||||
expect(controller.tasks.listTasks()[0].reviewState).toBe('none');
|
||||
});
|
||||
|
||||
it('tracks lifecycle history and intervals without duplicate same-status transitions', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Lifecycle task' });
|
||||
|
||||
expect(task.status).toBe('pending');
|
||||
expect(task.statusHistory).toHaveLength(1);
|
||||
expect(task.workIntervals).toBeUndefined();
|
||||
|
||||
const started = controller.tasks.startTask(task.id, 'bob');
|
||||
const startedAgain = controller.tasks.startTask(task.id, 'bob');
|
||||
const completed = controller.tasks.completeTask(task.id, 'bob');
|
||||
const completedAgain = controller.tasks.completeTask(task.id, 'bob');
|
||||
const deleted = controller.tasks.softDeleteTask(task.id, 'bob');
|
||||
const restored = controller.tasks.restoreTask(task.id, 'bob');
|
||||
|
||||
expect(started.status).toBe('in_progress');
|
||||
expect(startedAgain.statusHistory).toHaveLength(2);
|
||||
expect(startedAgain.workIntervals).toHaveLength(1);
|
||||
expect(startedAgain.workIntervals[0].startedAt).toBeTruthy();
|
||||
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completedAgain.statusHistory).toHaveLength(3);
|
||||
expect(completedAgain.workIntervals).toHaveLength(1);
|
||||
expect(completedAgain.workIntervals[0].completedAt).toBeTruthy();
|
||||
|
||||
expect(deleted.status).toBe('deleted');
|
||||
expect(deleted.deletedAt).toBeTruthy();
|
||||
expect(restored.status).toBe('pending');
|
||||
expect(restored.deletedAt).toBeUndefined();
|
||||
expect(restored.statusHistory).toHaveLength(5);
|
||||
expect(restored.statusHistory.map((entry) => entry.to)).toEqual([
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'deleted',
|
||||
'pending',
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists full inbox metadata through controller messages.sendMessage', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const sent = controller.messages.sendMessage({
|
||||
to: 'bob',
|
||||
from: 'team-lead',
|
||||
text: 'Need your review',
|
||||
summary: 'Review request',
|
||||
source: 'system_notification',
|
||||
leadSessionId: 'session-42',
|
||||
attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }],
|
||||
});
|
||||
|
||||
expect(sent.deliveredToInbox).toBe(true);
|
||||
expect(sent.messageId).toBeTruthy();
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].source).toBe('system_notification');
|
||||
expect(rows[0].leadSessionId).toBe('session-42');
|
||||
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
||||
});
|
||||
|
||||
it('moves review back to in_progress and notifies owner on requestChanges', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Needs revision', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
const updated = controller.review.requestChanges(task.id, {
|
||||
from: 'alice',
|
||||
comment: 'Please address review feedback.',
|
||||
});
|
||||
|
||||
expect(updated.status).toBe('in_progress');
|
||||
expect(updated.reviewState).toBe('none');
|
||||
expect(updated.comments.at(-1).type).toBe('review_request');
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows.at(-1).source).toBe('system_notification');
|
||||
expect(rows.at(-1).summary).toContain('Fix request');
|
||||
});
|
||||
|
||||
it('marks stale processes stopped during listing and supports unregister', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const processesPath = path.join(claudeDir, 'teams', 'my-team', 'processes.json');
|
||||
|
||||
fs.writeFileSync(
|
||||
processesPath,
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
id: 'stale-entry',
|
||||
pid: 999999,
|
||||
label: 'stale',
|
||||
registeredAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const listed = controller.processes.listProcesses();
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0].alive).toBe(false);
|
||||
expect(listed[0].stoppedAt).toBeTruthy();
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(processesPath, 'utf8'));
|
||||
expect(persisted[0].stoppedAt).toBeTruthy();
|
||||
|
||||
controller.processes.unregisterProcess({ id: 'stale-entry' });
|
||||
expect(controller.processes.listProcesses()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function createServer() {
|
|||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const server = createServer();
|
||||
server.start({
|
||||
void server.start({
|
||||
transportType: 'stdio',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState()),
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState())),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -29,7 +29,9 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
column: z.enum(['review', 'approved']),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, column }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column)),
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column))
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -40,7 +42,7 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId)),
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId))),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -50,7 +52,7 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers()),
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers())),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -61,7 +63,7 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
reviewer: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer)),
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer))),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -72,6 +74,8 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
reviewer: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer)),
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer))
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,8 +43,9 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
leadSessionId,
|
||||
attachments,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).messages.sendMessage({
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).messages.sendMessage({
|
||||
to,
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
|
|
@ -52,7 +53,8 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(source ? { source } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
...(attachments?.length ? { attachments } : {}),
|
||||
})
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,8 +34,9 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
url,
|
||||
claudeProcessId,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).processes.registerProcess({
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).processes.registerProcess({
|
||||
pid,
|
||||
label,
|
||||
...(from ? { from } : {}),
|
||||
|
|
@ -43,7 +44,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(port ? { port } : {}),
|
||||
...(url ? { url } : {}),
|
||||
...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
|
||||
})
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -54,7 +56,9 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses()),
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses())
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -65,7 +69,9 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
pid: z.number().int().positive(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, pid }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid })),
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid }))
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -76,6 +82,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
pid: z.number().int().positive(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, pid }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid })),
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid }))
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
reviewer: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, reviewer }) =>
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.requestReview(taskId, {
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.requestReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(reviewer ? { reviewer } : {}),
|
||||
})
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -39,12 +41,14 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
notifyOwner: z.boolean().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner }) =>
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.approveReview(taskId, {
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.approveReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(note ? { note } : {}),
|
||||
...(notifyOwner !== false ? { 'notify-owner': true } : {}),
|
||||
})
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -58,11 +62,13 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
comment: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, comment }) =>
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(comment ? { comment } : {}),
|
||||
})
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}),
|
||||
execute: async ({ teamName, claudeDir, subject, description, owner, blockedBy, related, prompt, startImmediately }) => {
|
||||
const controller = getController(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
controller.tasks.createTask({
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
controller.tasks.createTask({
|
||||
subject,
|
||||
...(description ? { description } : {}),
|
||||
...(owner ? { owner } : {}),
|
||||
|
|
@ -36,7 +37,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(related?.length ? { related: related.join(',') } : {}),
|
||||
...(prompt ? { prompt } : {}),
|
||||
...(startImmediately === false && owner ? { status: 'pending' } : {}),
|
||||
})
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -49,7 +51,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId)),
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -59,7 +61,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.listTasks()),
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.listTasks())),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -72,7 +74,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor)),
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor))
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -84,7 +88,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.startTask(taskId, actor)),
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.startTask(taskId, actor))),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -96,7 +100,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.completeTask(taskId, actor)),
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.completeTask(taskId, actor))
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -108,7 +114,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
owner: z.string().nullable(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, owner }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner)),
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner))
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -121,11 +129,13 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
from: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, text, from }) =>
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
})
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -151,14 +161,16 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
mimeType,
|
||||
noFallback,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
})
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -186,14 +198,16 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
mimeType,
|
||||
noFallback,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
})
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -206,10 +220,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
value: z.enum(['lead', 'user', 'clear']),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, value }) =>
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
||||
taskId,
|
||||
value === 'clear' ? null : value
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
|
@ -224,7 +240,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
relationship: relationshipTypeSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship)),
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship))
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -237,8 +255,10 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
relationship: relationshipTypeSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship)
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export function jsonTextContent(value: unknown): { content: Array<{ type: 'text'; text: string }> } {
|
||||
export function jsonTextContent(value: unknown): { content: { type: 'text'; text: string }[] } {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { registerTools } from '../src/tools';
|
|||
|
||||
type RegisteredTool = {
|
||||
name: string;
|
||||
parameters?: { safeParse: (value: unknown) => { success: boolean } };
|
||||
execute: (args: Record<string, unknown>) => Promise<unknown> | unknown;
|
||||
};
|
||||
|
||||
|
|
@ -28,6 +29,36 @@ function parseJsonToolResult(result: unknown) {
|
|||
|
||||
describe('agent-teams-mcp tools', () => {
|
||||
const tools = collectTools();
|
||||
const expectedToolNames = [
|
||||
'kanban_add_reviewer',
|
||||
'kanban_clear',
|
||||
'kanban_get',
|
||||
'kanban_list_reviewers',
|
||||
'kanban_remove_reviewer',
|
||||
'kanban_set_column',
|
||||
'message_send',
|
||||
'process_list',
|
||||
'process_register',
|
||||
'process_stop',
|
||||
'process_unregister',
|
||||
'review_approve',
|
||||
'review_request',
|
||||
'review_request_changes',
|
||||
'task_add_comment',
|
||||
'task_attach_comment_file',
|
||||
'task_attach_file',
|
||||
'task_briefing',
|
||||
'task_complete',
|
||||
'task_create',
|
||||
'task_get',
|
||||
'task_link',
|
||||
'task_list',
|
||||
'task_set_clarification',
|
||||
'task_set_owner',
|
||||
'task_set_status',
|
||||
'task_start',
|
||||
'task_unlink',
|
||||
] as const;
|
||||
|
||||
function getTool(name: string) {
|
||||
const tool = tools.get(name);
|
||||
|
|
@ -39,12 +70,24 @@ describe('agent-teams-mcp tools', () => {
|
|||
return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
|
||||
}
|
||||
|
||||
it('covers task clarification and comment attachment flows', async () => {
|
||||
it('registers the full expected MCP tool surface', () => {
|
||||
expect([...tools.keys()].sort()).toEqual([...expectedToolNames]);
|
||||
});
|
||||
|
||||
it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'alpha';
|
||||
const attachmentPath = path.join(claudeDir, 'note.txt');
|
||||
fs.writeFileSync(attachmentPath, 'ship it');
|
||||
|
||||
const dependencyTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Dependency',
|
||||
})
|
||||
);
|
||||
|
||||
const createdTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
|
|
@ -54,6 +97,46 @@ describe('agent-teams-mcp tools', () => {
|
|||
})
|
||||
);
|
||||
|
||||
const listedTasks = parseJsonToolResult(
|
||||
await getTool('task_list').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
})
|
||||
);
|
||||
expect(listedTasks).toHaveLength(2);
|
||||
|
||||
const linked = parseJsonToolResult(
|
||||
await getTool('task_link').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
targetId: dependencyTask.id,
|
||||
relationship: 'blocked-by',
|
||||
})
|
||||
);
|
||||
expect(linked.blockedBy).toContain(dependencyTask.id);
|
||||
|
||||
const unlinked = parseJsonToolResult(
|
||||
await getTool('task_unlink').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
targetId: dependencyTask.id,
|
||||
relationship: 'blocked-by',
|
||||
})
|
||||
);
|
||||
expect(unlinked.blockedBy ?? []).not.toContain(dependencyTask.id);
|
||||
|
||||
const owned = parseJsonToolResult(
|
||||
await getTool('task_set_owner').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
owner: 'alice',
|
||||
})
|
||||
);
|
||||
expect(owned.owner).toBe('alice');
|
||||
|
||||
const commented = parseJsonToolResult(
|
||||
await getTool('task_add_comment').execute({
|
||||
claudeDir,
|
||||
|
|
@ -80,6 +163,17 @@ describe('agent-teams-mcp tools', () => {
|
|||
|
||||
expect(attachment.filename).toBe('note.txt');
|
||||
|
||||
const taskAttachment = parseJsonToolResult(
|
||||
await getTool('task_attach_file').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
filePath: attachmentPath,
|
||||
mode: 'copy',
|
||||
})
|
||||
);
|
||||
expect(taskAttachment.filename).toBe('note.txt');
|
||||
|
||||
await getTool('task_set_clarification').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
|
|
@ -98,6 +192,17 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(loadedTask.needsClarification).toBe('user');
|
||||
expect(loadedTask.comments).toHaveLength(1);
|
||||
expect(loadedTask.comments[0].attachments).toHaveLength(1);
|
||||
expect(loadedTask.attachments).toHaveLength(1);
|
||||
|
||||
const started = parseJsonToolResult(
|
||||
await getTool('task_start').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
actor: 'alice',
|
||||
})
|
||||
);
|
||||
expect(started.status).toBe('in_progress');
|
||||
|
||||
await getTool('task_set_status').execute({
|
||||
claudeDir,
|
||||
|
|
@ -106,6 +211,21 @@ describe('agent-teams-mcp tools', () => {
|
|||
status: 'completed',
|
||||
});
|
||||
|
||||
parseJsonToolResult(
|
||||
await getTool('kanban_add_reviewer').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
reviewer: 'alice',
|
||||
})
|
||||
);
|
||||
const reviewers = parseJsonToolResult(
|
||||
await getTool('kanban_list_reviewers').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
})
|
||||
);
|
||||
expect(reviewers).toEqual(['alice']);
|
||||
|
||||
const reviewRequested = parseJsonToolResult(
|
||||
await getTool('review_request').execute({
|
||||
claudeDir,
|
||||
|
|
@ -117,11 +237,87 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
|
||||
expect(reviewRequested.reviewState).toBe('review');
|
||||
|
||||
const approved = parseJsonToolResult(
|
||||
await getTool('review_approve').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
from: 'lead',
|
||||
note: 'Looks good',
|
||||
notifyOwner: true,
|
||||
})
|
||||
);
|
||||
expect(approved.reviewState).toBe('approved');
|
||||
|
||||
const kanbanState = parseJsonToolResult(
|
||||
await getTool('kanban_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
})
|
||||
);
|
||||
expect(kanbanState.tasks[createdTask.id].column).toBe('approved');
|
||||
|
||||
const briefing = await getTool('task_briefing').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
memberName: 'alice',
|
||||
});
|
||||
expect((briefing as { content: Array<{ text: string }> }).content[0]?.text).toContain(
|
||||
'Review MCP adapter'
|
||||
);
|
||||
});
|
||||
|
||||
it('covers process register/list/stop without legacy stdout leaking into results', async () => {
|
||||
it('covers review_request_changes and full process lifecycle tools', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'beta';
|
||||
|
||||
const createdTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Needs revision',
|
||||
owner: 'bob',
|
||||
})
|
||||
);
|
||||
|
||||
await getTool('task_complete').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
actor: 'bob',
|
||||
});
|
||||
|
||||
await getTool('review_request').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
from: 'lead',
|
||||
reviewer: 'alice',
|
||||
});
|
||||
|
||||
const changesRequested = parseJsonToolResult(
|
||||
await getTool('review_request_changes').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
from: 'alice',
|
||||
comment: 'Please revise this section.',
|
||||
})
|
||||
);
|
||||
|
||||
expect(changesRequested.status).toBe('in_progress');
|
||||
expect(changesRequested.reviewState).toBe('none');
|
||||
|
||||
const kanbanCleared = parseJsonToolResult(
|
||||
await getTool('kanban_clear').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
})
|
||||
);
|
||||
expect(kanbanCleared.tasks[createdTask.id]).toBeUndefined();
|
||||
|
||||
const pid = process.pid;
|
||||
|
||||
const registered = parseJsonToolResult(
|
||||
|
|
@ -159,6 +355,15 @@ describe('agent-teams-mcp tools', () => {
|
|||
|
||||
expect(stopped.pid).toBe(pid);
|
||||
expect(typeof stopped.stoppedAt).toBe('string');
|
||||
|
||||
const unregistered = parseJsonToolResult(
|
||||
await getTool('process_unregister').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
pid,
|
||||
})
|
||||
);
|
||||
expect(unregistered).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists full message metadata through message_send', async () => {
|
||||
|
|
@ -186,4 +391,21 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(rows[0].leadSessionId).toBe('session-42');
|
||||
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
||||
});
|
||||
|
||||
it('exposes zod schemas that reject obviously invalid payloads', () => {
|
||||
expect(
|
||||
getTool('task_create').parameters?.safeParse({
|
||||
teamName: 'demo',
|
||||
claudeDir: '/tmp/demo',
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
getTool('process_register').parameters?.safeParse({
|
||||
teamName: 'demo',
|
||||
pid: 0,
|
||||
label: '',
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@
|
|||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||
"build:workspace": "pnpm build && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||
"test:workspace": "pnpm test && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test",
|
||||
"check": "pnpm typecheck:workspace && pnpm lint && pnpm test:workspace && pnpm build:workspace",
|
||||
"check:workspace": "pnpm typecheck:workspace && pnpm test:workspace && pnpm build:workspace",
|
||||
"check": "pnpm check:workspace && pnpm lint",
|
||||
"fix": "pnpm lint:fix && pnpm format",
|
||||
"quality": "pnpm check && pnpm format:check && npx knip",
|
||||
"test:chunks": "tsx test/test-chunk-building.ts",
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ describe('ipc teams handlers', () => {
|
|||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
})),
|
||||
reconcileTeamArtifacts: vi.fn(async () => undefined),
|
||||
deleteTeam: vi.fn(async () => undefined),
|
||||
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
|
||||
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
|
||||
|
|
@ -317,6 +318,19 @@ describe('ipc teams handlers', () => {
|
|||
expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => {
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { teamName: string };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.teamName).toBe('my-team');
|
||||
expect(service.getTeamData).toHaveBeenCalledWith('my-team');
|
||||
expect(service.reconcileTeamArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('createTask prompt validation', () => {
|
||||
it('accepts valid prompt string', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE_TASK)!;
|
||||
|
|
|
|||
21
test/renderer/components/team/activity/ActivityItem.test.ts
Normal file
21
test/renderer/components/team/activity/ActivityItem.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSystemMessageLabel } from '@renderer/components/team/activity/ActivityItem';
|
||||
|
||||
describe('ActivityItem legacy system message fallback', () => {
|
||||
it('recognizes historical assignment and review message wording', () => {
|
||||
expect(getSystemMessageLabel('New task assigned to you: #abcd1234 "Implement feature".')).toBe(
|
||||
'Task assignment'
|
||||
);
|
||||
expect(getSystemMessageLabel('Task #abcd1234 approved by reviewer.')).toBe('Task approved');
|
||||
expect(getSystemMessageLabel('Task #abcd1234 needs fixes before approval.')).toBe(
|
||||
'Review changes requested'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat new controller-authored summaries as legacy system noise', () => {
|
||||
expect(getSystemMessageLabel('Review request for #abcd1234')).toBeNull();
|
||||
expect(getSystemMessageLabel('Approved abcd1234')).toBeNull();
|
||||
expect(getSystemMessageLabel('Fix request for abcd1234')).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue