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:
iliya 2026-03-07 19:09:58 +02:00
parent 48e5d9d6cd
commit d486510c9e
13 changed files with 507 additions and 52 deletions

View file

@ -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'

View file

@ -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([]);
});
});

View file

@ -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',
});
}

View file

@ -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))
),
});
}

View file

@ -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 } : {}),
})
})
)
),
});
}

View file

@ -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 }))
),
});
}

View file

@ -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 } : {}),
})
})
)
),
});
}

View file

@ -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)
)
),
});

View file

@ -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: [
{

View file

@ -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);
});
});

View file

@ -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",

View file

@ -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)!;

View 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();
});
});