diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23bee75d..5ad0b160 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 0d89b767..18c1b3c8 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -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([]); + }); }); diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index cc957478..8cea8096 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -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', }); } diff --git a/mcp-server/src/tools/kanbanTools.ts b/mcp-server/src/tools/kanbanTools.ts index 5ae3c181..a086db38 100644 --- a/mcp-server/src/tools/kanbanTools.ts +++ b/mcp-server/src/tools/kanbanTools.ts @@ -17,7 +17,7 @@ export function registerKanbanTools(server: Pick) { ...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) { 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) { 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) { ...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) { 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) { 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)) + ), }); } diff --git a/mcp-server/src/tools/messageTools.ts b/mcp-server/src/tools/messageTools.ts index c0c1f97e..59030abf 100644 --- a/mcp-server/src/tools/messageTools.ts +++ b/mcp-server/src/tools/messageTools.ts @@ -43,8 +43,9 @@ export function registerMessageTools(server: Pick) { 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) { ...(source ? { source } : {}), ...(leadSessionId ? { leadSessionId } : {}), ...(attachments?.length ? { attachments } : {}), - }) + }) + ) ), }); } diff --git a/mcp-server/src/tools/processTools.ts b/mcp-server/src/tools/processTools.ts index dc9b7f3e..4c682835 100644 --- a/mcp-server/src/tools/processTools.ts +++ b/mcp-server/src/tools/processTools.ts @@ -34,8 +34,9 @@ export function registerProcessTools(server: Pick) { 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) { ...(port ? { port } : {}), ...(url ? { url } : {}), ...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}), - }) + }) + ) ), }); @@ -54,7 +56,9 @@ export function registerProcessTools(server: Pick) { ...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) { 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) { 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 })) + ), }); } diff --git a/mcp-server/src/tools/reviewTools.ts b/mcp-server/src/tools/reviewTools.ts index a263ad5d..2d863d1b 100644 --- a/mcp-server/src/tools/reviewTools.ts +++ b/mcp-server/src/tools/reviewTools.ts @@ -20,11 +20,13 @@ export function registerReviewTools(server: Pick) { 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) { 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) { 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 } : {}), - }) + }) + ) ), }); } diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 61339b5d..8921a11f 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -27,8 +27,9 @@ export function registerTaskTools(server: Pick) { }), 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) { ...(related?.length ? { related: related.join(',') } : {}), ...(prompt ? { prompt } : {}), ...(startImmediately === false && owner ? { status: 'pending' } : {}), - }) + }) + ) ); }, }); @@ -49,7 +51,7 @@ export function registerTaskTools(server: Pick) { 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) { ...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) { 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) { 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) { 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) { 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) { 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) { 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) { 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) { 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) { 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) { 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) + ) ), }); diff --git a/mcp-server/src/utils/format.ts b/mcp-server/src/utils/format.ts index d216113e..ce7873b4 100644 --- a/mcp-server/src/utils/format.ts +++ b/mcp-server/src/utils/format.ts @@ -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: [ { diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 956481f0..19dacce3 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -6,6 +6,7 @@ import { registerTools } from '../src/tools'; type RegisteredTool = { name: string; + parameters?: { safeParse: (value: unknown) => { success: boolean } }; execute: (args: Record) => Promise | 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); + }); }); diff --git a/package.json b/package.json index 6fc83b91..bb71c7c3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 26ac5d8c..d39c516b 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.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)!; diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts new file mode 100644 index 00000000..90d51ec9 --- /dev/null +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -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(); + }); +});