import fs from 'fs'; import http from 'http'; import os from 'os'; import path from 'path'; import { AGENT_TEAMS_REGISTERED_TOOL_NAMES, registerTools } from '../src/tools'; type RegisteredTool = { description?: string; name: string; parameters?: { safeParse: (value: unknown) => { success: boolean } }; execute: (args: Record) => Promise | unknown; }; function collectTools() { const tools = new Map(); registerTools({ addTool(config: RegisteredTool) { tools.set(config.name, config); }, } as never); return tools; } function parseJsonToolResult(result: unknown) { const text = (result as { content: Array<{ text: string }> }).content[0]?.text; return JSON.parse(text); } describe('agent-teams-mcp tools', () => { const tools = collectTools(); function getTool(name: string) { const tool = tools.get(name); expect(tool).toBeDefined(); return tool!; } function makeClaudeDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-')); } function writeTeamConfig( claudeDir: string, teamName: string, config: { name?: string; language?: string; projectPath?: string; members: Array>; } ) { const teamDir = path.join(claudeDir, 'teams', teamName); fs.mkdirSync(teamDir, { recursive: true }); fs.writeFileSync( path.join(teamDir, 'config.json'), JSON.stringify( { name: config.name ?? teamName, ...(config.language ? { language: config.language } : {}), ...(config.projectPath ? { projectPath: config.projectPath } : {}), members: config.members, }, null, 2 ) ); } async function startControlServer( handler: (request: { method?: string; url?: string; body?: unknown; }) => Promise<{ statusCode?: number; body: unknown }> | { statusCode?: number; body: unknown } ) { const server = http.createServer(async (req, res) => { const chunks: Buffer[] = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', async () => { try { const bodyText = Buffer.concat(chunks).toString('utf8'); const body = bodyText ? JSON.parse(bodyText) : undefined; const result = await handler({ method: req.method, url: req.url, body }); res.writeHead(result.statusCode ?? 200, { 'content-type': 'application/json' }); res.end(JSON.stringify(result.body)); } catch (error) { res.writeHead(500, { 'content-type': 'application/json' }); res.end( JSON.stringify({ error: error instanceof Error ? error.message : String(error) }) ); } }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); const address = server.address(); if (!address || typeof address === 'string') { throw new Error('Failed to bind control server'); } return { baseUrl: `http://127.0.0.1:${address.port}`, close: async () => await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())) ), }; } it('registers the full expected MCP tool surface', () => { expect([...tools.keys()].sort()).toEqual([...AGENT_TEAMS_REGISTERED_TOOL_NAMES].sort()); }); it('accepts explicit conversation threading fields for cross_team_send', () => { const parsed = getTool('cross_team_send').parameters?.safeParse({ teamName: 'alpha', toTeam: 'beta', text: 'Reply', conversationId: 'conv-1', replyToConversationId: 'conv-1', taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'alpha' }], }); expect(parsed?.success).toBe(true); }); it('launches and stops teams through the runtime MCP tools', async () => { const claudeDir = makeClaudeDir(); writeTeamConfig(claudeDir, 'alpha', { members: [{ name: 'lead', role: 'team-lead' }], }); const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; const server = await startControlServer(async ({ method, url, body }) => { calls.push({ method, url, body }); if (method === 'POST' && url === '/api/teams/alpha/launch') { return { body: { runId: 'run-555' } }; } if (method === 'GET' && url === '/api/teams/provisioning/run-555') { return { body: { runId: 'run-555', teamName: 'alpha', state: 'ready', message: 'Ready', startedAt: '2026-03-12T00:00:00.000Z', updatedAt: '2026-03-12T00:00:02.000Z', }, }; } if (method === 'POST' && url === '/api/teams/alpha/stop') { return { body: { teamName: 'alpha', isAlive: false, runId: null, progress: null, }, }; } if (method === 'GET' && url === '/api/teams/alpha/runtime') { return { body: { teamName: 'alpha', isAlive: false, runId: null, progress: null, }, }; } return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; }); try { const launched = parseJsonToolResult( await getTool('team_launch').execute({ claudeDir, teamName: 'alpha', cwd: '/tmp/project', controlUrl: server.baseUrl, }) ); expect(launched.runId).toBe('run-555'); expect(launched.isAlive).toBe(true); expect(launched.progress.state).toBe('ready'); const stopped = parseJsonToolResult( await getTool('team_stop').execute({ claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, }) ); expect(stopped.isAlive).toBe(false); expect(calls).toEqual([ { method: 'POST', url: '/api/teams/alpha/launch', body: { cwd: '/tmp/project' }, }, { method: 'GET', url: '/api/teams/provisioning/run-555', body: undefined, }, { method: 'POST', url: '/api/teams/alpha/stop', body: undefined, }, { method: 'GET', url: '/api/teams/alpha/runtime', body: undefined, }, ]); } finally { await server.close(); } }); it('lists, gets, and creates teams through the local control API', async () => { const claudeDir = makeClaudeDir(); const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; const server = await startControlServer(async ({ method, url, body }) => { calls.push({ method, url, body }); if (method === 'GET' && url === '/api/teams') { return { body: [ { teamName: 'alpha', displayName: 'Alpha', description: '', memberCount: 1, taskCount: 0, lastActivity: null, pendingCreate: true, }, ], }; } if (method === 'GET' && url === '/api/teams/alpha') { return { body: { teamName: 'alpha', members: [{ name: 'builder', role: 'Engineer' }], tasks: [], }, }; } if (method === 'POST' && url === '/api/teams') { return { statusCode: 201, body: { teamName: 'alpha' } }; } return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; }); try { const listed = parseJsonToolResult( await getTool('team_list').execute({ claudeDir, controlUrl: server.baseUrl, }) ); expect(listed[0].teamName).toBe('alpha'); const fetched = parseJsonToolResult( await getTool('team_get').execute({ claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, }) ); expect(fetched.teamName).toBe('alpha'); const created = parseJsonToolResult( await getTool('team_create').execute({ claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, displayName: 'Alpha', members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }], cwd: '/tmp/project', providerId: 'codex', model: 'gpt-5.2', effort: 'high', fastMode: 'on', }) ); expect(created.teamName).toBe('alpha'); expect(calls).toEqual([ { method: 'GET', url: '/api/teams', body: undefined, }, { method: 'GET', url: '/api/teams/alpha', body: undefined, }, { method: 'POST', url: '/api/teams', body: { teamName: 'alpha', displayName: 'Alpha', members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }], cwd: '/tmp/project', providerId: 'codex', model: 'gpt-5.2', effort: 'high', fastMode: 'on', }, }, ]); } finally { await server.close(); } }); it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => { const claudeDir = makeClaudeDir(); writeTeamConfig(claudeDir, 'alpha', { members: [ { name: 'lead', role: 'team-lead' }, { name: 'alice', role: 'developer' }, ], }); const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; const server = await startControlServer(async ({ method, url, body }) => { calls.push({ method, url, body }); return { body: { ok: true, state: 'accepted' } }; }); try { await getTool('runtime_bootstrap_checkin').execute({ claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, runId: 'run-oc', memberName: 'alice', runtimeSessionId: 'ses-1', }); await getTool('runtime_deliver_message').execute({ claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, idempotencyKey: 'idem-1', runId: 'run-oc', fromMemberName: 'alice', runtimeSessionId: 'ses-1', to: 'user', text: 'hello', }); await getTool('runtime_task_event').execute({ claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, idempotencyKey: 'idem-task-1', runId: 'run-oc', memberName: 'alice', runtimeSessionId: 'ses-1', taskId: 'task-1', event: 'started', }); await getTool('runtime_heartbeat').execute({ claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, runId: 'run-oc', memberName: 'alice', runtimeSessionId: 'ses-1', }); expect(calls.map((call) => call.url)).toEqual([ '/api/teams/alpha/opencode/runtime/bootstrap-checkin', '/api/teams/alpha/opencode/runtime/deliver-message', '/api/teams/alpha/opencode/runtime/task-event', '/api/teams/alpha/opencode/runtime/heartbeat', ]); expect(calls[1].body).toEqual({ teamName: 'alpha', idempotencyKey: 'idem-1', runId: 'run-oc', fromMemberName: 'alice', runtimeSessionId: 'ses-1', to: 'user', text: 'hello', }); } finally { await server.close(); } }); it('forwards member work sync MCP tools through the app validator bridge', async () => { const claudeDir = makeClaudeDir(); writeTeamConfig(claudeDir, 'alpha', { members: [ { name: 'lead', role: 'team-lead' }, { name: 'alice', role: 'developer' }, ], }); const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; const server = await startControlServer(async ({ method, url, body }) => { calls.push({ method, url, body }); if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/alice/refresh') { return { body: { teamName: 'alpha', memberName: 'alice', state: 'needs_sync', agenda: { teamName: 'alpha', memberName: 'alice', generatedAt: '2026-04-29T00:00:00.000Z', fingerprint: 'agenda:v1:abc', items: [], diagnostics: [], }, reportToken: 'wrs:v1.test.token', reportTokenExpiresAt: '2026-04-29T00:15:00.000Z', evaluatedAt: '2026-04-29T00:00:00.000Z', diagnostics: ['no_current_report'], }, }; } if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/report') { return { body: { accepted: true, code: 'accepted', status: body } }; } return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; }); try { const status = parseJsonToolResult( await getTool('member_work_sync_status').execute({ claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, from: 'alice', }) ); expect(status.state).toBe('needs_sync'); const report = parseJsonToolResult( await getTool('member_work_sync_report').execute({ claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, memberName: 'alice', state: 'still_working', agendaFingerprint: 'agenda:v1:abc', reportToken: 'wrs:v1.test.token', taskIds: ['task-1'], note: 'Still working', leaseTtlMs: 120000, }) ); expect(report.accepted).toBe(true); expect(calls).toEqual([ { method: 'POST', url: '/api/teams/alpha/member-work-sync/alice/refresh', body: {}, }, { method: 'POST', url: '/api/teams/alpha/member-work-sync/report', body: { teamName: 'alpha', memberName: 'alice', state: 'still_working', agendaFingerprint: 'agenda:v1:abc', reportToken: 'wrs:v1.test.token', taskIds: ['task-1'], note: 'Still working', leaseTtlMs: 120000, }, }, ]); } finally { await server.close(); } }); it('discovers the control endpoint from the published state file', async () => { const claudeDir = makeClaudeDir(); writeTeamConfig(claudeDir, 'alpha', { members: [{ name: 'lead', role: 'team-lead' }], }); const statePath = path.join(claudeDir, 'team-control-api.json'); const server = await startControlServer(async ({ method, url }) => { if (method === 'POST' && url === '/api/teams/alpha/launch') { return { body: { runId: 'run-state-file' } }; } if (method === 'GET' && url === '/api/teams/provisioning/run-state-file') { return { body: { runId: 'run-state-file', teamName: 'alpha', state: 'ready', message: 'Ready', startedAt: '2026-03-12T00:00:00.000Z', updatedAt: '2026-03-12T00:00:02.000Z', }, }; } return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; }); try { fs.writeFileSync( statePath, JSON.stringify({ baseUrl: server.baseUrl, updatedAt: new Date().toISOString() }, null, 2) ); const launched = parseJsonToolResult( await getTool('team_launch').execute({ teamName: 'alpha', claudeDir, cwd: '/tmp/project', }) ); expect(launched.runId).toBe('run-state-file'); expect(launched.progress.state).toBe('ready'); } finally { await server.close(); } }); it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => { const claudeDir = makeClaudeDir(); const teamName = 'alpha'; writeTeamConfig(claudeDir, teamName, { language: 'en', members: [ { name: 'lead', role: 'team-lead' }, { name: 'alice', role: 'developer' }, ], }); 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, teamName, subject: 'Review MCP adapter', owner: 'alice', createdBy: 'ui-fixer', }) ); expect(createdTask.status).toBe('pending'); expect(createdTask.historyEvents?.[0]?.actor).toBe('ui-fixer'); 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); await getTool('task_set_owner').execute({ claudeDir, teamName, taskId: createdTask.id, owner: null, actor: 'lead', }); const owned = parseJsonToolResult( await getTool('task_set_owner').execute({ claudeDir, teamName, taskId: createdTask.id, owner: 'alice', actor: 'lead', }) ); expect(owned.owner).toBe('alice'); const ownedFull = parseJsonToolResult( await getTool('task_get').execute({ claudeDir, teamName, taskId: createdTask.id, }) ); expect(ownedFull.historyEvents.at(-1)).toMatchObject({ type: 'owner_changed', to: 'alice', actor: 'lead', }); const commented = parseJsonToolResult( await getTool('task_add_comment').execute({ claudeDir, teamName, taskId: createdTask.id, text: 'Need one more check', from: 'lead', }) ); const commentId = commented.commentId; expect(commentId).toBeTruthy(); const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'); const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8')); expect(ownerInbox.at(-1).summary).toContain(`#${createdTask.displayId}`); expect(ownerInbox.at(-1).text).toContain('Need one more check'); const attachment = parseJsonToolResult( await getTool('task_attach_comment_file').execute({ claudeDir, teamName, taskId: createdTask.id, commentId, filePath: attachmentPath, mode: 'copy', }) ); 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, taskId: createdTask.id, value: 'user', }); const loadedTask = parseJsonToolResult( await getTool('task_get').execute({ claudeDir, teamName, taskId: createdTask.id, }) ); 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, teamName, taskId: createdTask.id, 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, teamName, taskId: createdTask.id, from: 'lead', reviewer: 'alice', leadSessionId: 'session-review-1', }) ); expect(reviewRequested.reviewState).toBe('review'); const reviewerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'); const reviewerInbox = JSON.parse(fs.readFileSync(reviewerInboxPath, 'utf8')); expect(reviewerInbox.at(-1).leadSessionId).toBe('session-review-1'); const approved = parseJsonToolResult( await getTool('review_approve').execute({ claudeDir, teamName, taskId: createdTask.id, from: 'lead', note: 'Looks good', notifyOwner: true, leadSessionId: 'session-review-1', }) ); expect(approved.reviewState).toBe('approved'); { const approvedInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'); const approvedInbox = JSON.parse(fs.readFileSync(approvedInboxPath, 'utf8')); expect(approvedInbox.at(-1).leadSessionId).toBe('session-review-1'); } 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' ); const memberBriefing = await getTool('member_briefing').execute({ claudeDir, teamName, memberName: 'alice', }); const memberBriefingText = (memberBriefing as { content: Array<{ text: string }> }).content[0] ?.text; expect(memberBriefingText).toContain('Member briefing for alice on team "alpha" (alpha).'); expect(memberBriefingText).toContain( 'Use task_briefing as your primary working queue whenever you need to see assigned work.' ); expect(memberBriefingText).toContain( 'Use task_list only to search/browse inventory rows, not as your working queue.' ); expect(memberBriefingText).toContain('Review MCP adapter'); expect(memberBriefingText).toContain('Full details in task comment e5f6a7b8'); expect(memberBriefingText).not.toContain('task_get_comment {'); const openCodeMemberBriefing = await getTool('member_briefing').execute({ claudeDir, teamName, memberName: 'alice', runtimeProvider: 'opencode', }); const openCodeMemberBriefingText = ( openCodeMemberBriefing as { content: Array<{ text: string }> } ).content[0]?.text; expect(openCodeMemberBriefingText).toContain('agent-teams_message_send'); expect(openCodeMemberBriefingText).toContain('OpenCode bootstrap silence rule'); expect(openCodeMemberBriefingText).toContain('stop and wait silently'); expect(openCodeMemberBriefingText).toContain('Full details in task comment e5f6a7b8'); expect(openCodeMemberBriefingText).toContain( 'Never invent placeholder task refs such as #00000000' ); expect(openCodeMemberBriefingText).not.toContain('task_get_comment {'); expect(openCodeMemberBriefingText).not.toContain('notify your team lead via SendMessage'); }); it('keeps owner-backed MCP tasks pending by default, supports explicit startImmediately, sends owner notifications, and returns compact task_briefing output', async () => { const claudeDir = makeClaudeDir(); const teamName = 'gamma'; writeTeamConfig(claudeDir, teamName, { language: 'en', projectPath: '/tmp/gamma-project', members: [ { name: 'lead', role: 'team-lead' }, { name: 'alice', role: 'developer', workflow: 'Stay focused' }, ], }); const queuedTask = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Queued work', description: 'Pending description should stay out of briefing details', owner: 'alice', prompt: 'Read the plan before starting.', }) ); expect(queuedTask.status).toBe('pending'); const activeTask = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Active work', description: 'This one is already in progress', owner: 'alice', startImmediately: true, }) ); expect(activeTask.status).toBe('in_progress'); await getTool('task_add_comment').execute({ claudeDir, teamName, taskId: activeTask.id, text: 'Investigating the active task now.', from: 'alice', }); const completedTask = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Done work', description: 'Completed description should also stay compact', owner: 'alice', }) ); await getTool('task_complete').execute({ claudeDir, teamName, taskId: completedTask.id, actor: 'alice', }); const unassignedTask = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Assign later', }) ); await getTool('task_set_owner').execute({ claudeDir, teamName, taskId: unassignedTask.id, owner: 'alice', }); const queuedByHashRef = parseJsonToolResult( await getTool('task_get').execute({ claudeDir, teamName, taskId: `#${queuedTask.displayId}`, }) ); expect(queuedByHashRef.id).toBe(queuedTask.id); const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'); const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8')); expect(ownerInbox).toHaveLength(4); expect(ownerInbox[0].summary).toContain(`#${queuedTask.displayId}`); expect(ownerInbox[0].text).toContain('task_get'); expect(ownerInbox[0].text).toContain('task_start'); expect(ownerInbox[0].text).toContain('task_add_comment'); expect(ownerInbox[0].text).toContain('Read the plan before starting.'); expect(ownerInbox[0].text).toContain( 'If you are idle and this task is ready to start, start it now.' ); expect(ownerInbox[0].text).toContain( 'If you are busy, blocked, or still need more context, immediately add a short task comment' ); expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`); expect(ownerInbox[3].text).toContain( 'If you are idle and this task is ready to start, start it now.' ); expect(ownerInbox[3].text).toContain('task_add_comment'); const briefing = (await getTool('task_briefing').execute({ claudeDir, teamName, memberName: 'alice', })) as { content: Array<{ text: string }> }; const briefingText = briefing.content[0]?.text ?? ''; expect(briefingText).toContain( 'Primary queue for alice. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.' ); expect(briefingText).toContain( 'Use task_list only to search/browse inventory rows, not as your working queue.' ); expect(briefingText).toContain('Actionable:'); expect(briefingText).toContain(`#${activeTask.displayId}`); expect(briefingText).toContain('Description: This one is already in progress'); expect(briefingText).toContain('Investigating the active task now.'); expect(briefingText).toContain(`#${queuedTask.displayId}`); expect(briefingText).not.toContain('Pending description should stay out of briefing details'); expect(briefingText).toContain('Awareness:'); expect(briefingText).toContain(`#${completedTask.displayId}`); expect(briefingText).not.toContain('Completed description should also stay compact'); const memberBriefing = (await getTool('member_briefing').execute({ claudeDir, teamName, memberName: 'alice', })) as { content: Array<{ text: string }> }; const memberBriefingText = memberBriefing.content[0]?.text ?? ''; expect(memberBriefingText).toContain( 'You must NOT start work, claim tasks, or improvise task/process protocol' ); expect(memberBriefingText).toContain( 'A newly assigned task must NOT remain silently pending/TODO. If you are idle and the task is ready to start, start it now.' ); expect(memberBriefingText).toContain('reason and your best ETA or what you are waiting on'); expect(memberBriefingText).toContain('IMPORTANT: Communicate in English.'); expect(memberBriefingText).toContain( 'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.' ); expect(memberBriefingText).toContain( 'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):' ); expect(memberBriefingText).toContain('Task briefing for alice:'); expect(memberBriefingText).toContain(`#${activeTask.displayId}`); fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true }); fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'carol.json'), '[]'); fs.writeFileSync( path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), '[]' ); fs.writeFileSync( path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), '[]' ); const inboxResolvedBriefing = (await getTool('member_briefing').execute({ claudeDir, teamName, memberName: 'carol', })) as { content: Array<{ text: string }> }; const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? ''; expect(inboxResolvedBriefingText).toContain( 'Member briefing for carol on team "gamma" (gamma).' ); expect(inboxResolvedBriefingText).toContain('Role: team member.'); await expect( getTool('member_briefing').execute({ claudeDir, teamName, memberName: 'dave', }) ).rejects.toThrow('Member not found in team metadata or inboxes: dave'); await expect( getTool('member_briefing').execute({ claudeDir, teamName, memberName: 'cross_team_send', }) ).rejects.toThrow('Member not found in team metadata or inboxes: cross_team_send'); await expect( getTool('member_briefing').execute({ claudeDir, teamName, memberName: 'other-team.alice', }) ).rejects.toThrow('Member not found in team metadata or inboxes: other-team.alice'); expect(inboxResolvedBriefingText).not.toContain( 'Warning: Member metadata was not found in config.json, members.meta.json, or inbox files yet.' ); }); it('returns compact lead_briefing output and filtered task_list inventory', async () => { const claudeDir = makeClaudeDir(); const teamName = 'lead-queue'; writeTeamConfig(claudeDir, teamName, { members: [ { name: 'lead', role: 'team-lead' }, { name: 'alice', role: 'developer' }, { name: 'bob', role: 'reviewer' }, ], }); const queuedTask = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Queued work', owner: 'alice', }) ); const unassignedTask = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Unassigned work', }) ); const reviewTask = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Awaiting reviewer', owner: 'alice', }) ); await getTool('task_complete').execute({ claudeDir, teamName, taskId: reviewTask.id, actor: 'alice', }); await getTool('review_request').execute({ claudeDir, teamName, taskId: reviewTask.id, from: 'lead', reviewer: 'bob', }); const leadBriefing = (await getTool('lead_briefing').execute({ claudeDir, teamName, })) as { content: Array<{ text: string }> }; const leadBriefingText = leadBriefing.content[0]?.text ?? ''; expect(leadBriefingText).toContain('Lead queue for lead on team "lead-queue":'); expect(leadBriefingText).toContain( 'Primary lead queue. Sections below already represent lead-owned actions or watch-only context.' ); expect(leadBriefingText).toContain( 'Use task_list only for search, filtering, and drill-down inventory lookups.' ); expect(leadBriefingText).toContain('Needs owner assignment:'); expect(leadBriefingText).toContain(`#${unassignedTask.displayId}`); expect(leadBriefingText).toContain('Watching:'); expect(leadBriefingText).toContain(`#${reviewTask.displayId}`); const reviewInventory = parseJsonToolResult( await getTool('task_list').execute({ claudeDir, teamName, reviewState: 'review', kanbanColumn: 'review', }) ); expect(reviewInventory).toHaveLength(1); expect(reviewInventory[0].id).toBe(reviewTask.id); const ownerPendingInventory = parseJsonToolResult( await getTool('task_list').execute({ claudeDir, teamName, owner: 'alice', status: 'pending', }) ); expect(ownerPendingInventory).toHaveLength(1); expect(ownerPendingInventory[0].id).toBe(queuedTask.id); }); it('covers review_request_changes and full process lifecycle tools', async () => { const claudeDir = makeClaudeDir(); const teamName = 'beta'; writeTeamConfig(claudeDir, teamName, { members: [ { name: 'lead', role: 'team-lead' }, { name: 'alice', role: 'reviewer' }, { name: 'bob', role: 'developer' }, ], }); 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', leadSessionId: 'session-review-2', }); const changesRequested = parseJsonToolResult( await getTool('review_request_changes').execute({ claudeDir, teamName, taskId: createdTask.id, from: 'alice', comment: 'Please revise this section.', leadSessionId: 'session-review-2', }) ); expect(changesRequested.status).toBe('pending'); expect(changesRequested.reviewState).toBe('needsFix'); const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'bob.json'); const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8')); expect(ownerInbox.at(-1).leadSessionId).toBe('session-review-2'); expect(ownerInbox.at(-1).text).toContain('moved back to pending'); const taskByHashRef = parseJsonToolResult( await getTool('task_get').execute({ claudeDir, teamName, taskId: `#${createdTask.displayId}`, }) ); expect(taskByHashRef.reviewState).toBe('needsFix'); const listedTasks = parseJsonToolResult( await getTool('task_list').execute({ claudeDir, teamName, }) ); expect( listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState ).toBe('needsFix'); const kanbanCleared = parseJsonToolResult( await getTool('kanban_clear').execute({ claudeDir, teamName, taskId: createdTask.id, }) ); expect(kanbanCleared.tasks[createdTask.id]).toBeUndefined(); // review_start: cannot move pending/non-review work into review by itself const pendingTask = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Start review test', owner: 'bob', }) ); await expect( getTool('review_start').execute({ claudeDir, teamName, taskId: pendingTask.id, from: 'alice', }) ).rejects.toThrow('must be completed before starting review'); const pid = process.pid; const registered = parseJsonToolResult( await getTool('process_register').execute({ claudeDir, teamName, pid, label: 'vite', command: 'pnpm dev', from: 'lead', port: 3000, }) ); expect(registered.pid).toBe(pid); expect(registered.label).toBe('vite'); const listed = parseJsonToolResult( await getTool('process_list').execute({ claudeDir, teamName, }) ); expect(listed).toHaveLength(1); expect(listed[0].pid).toBe(pid); const stopped = parseJsonToolResult( await getTool('process_stop').execute({ claudeDir, teamName, pid, }) ); 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('rejects public lifecycle bypasses through kanban_set_column and task_set_status', async () => { const claudeDir = makeClaudeDir(); const teamName = 'public-bypass-guards'; writeTeamConfig(claudeDir, teamName, { members: [ { name: 'lead', role: 'team-lead' }, { name: 'alice', role: 'reviewer' }, { name: 'bob', role: 'developer' }, ], }); const pendingTask = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Cannot approve directly', owner: 'bob', }) ); await expect( getTool('kanban_set_column').execute({ claudeDir, teamName, taskId: pendingTask.id, column: 'approved', }) ).rejects.toThrow('must be completed before moving to APPROVED column'); const reviewTask = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Delete cleanup through generic status', owner: 'bob', }) ); await getTool('task_complete').execute({ claudeDir, teamName, taskId: reviewTask.id, actor: 'bob', }); await getTool('review_request').execute({ claudeDir, teamName, taskId: reviewTask.id, from: 'lead', reviewer: 'alice', }); const deleted = parseJsonToolResult( await getTool('task_set_status').execute({ claudeDir, teamName, taskId: reviewTask.id, status: 'deleted', actor: 'lead', }) ); expect(deleted.status).toBe('deleted'); expect(deleted.reviewState).toBe('none'); const kanbanState = parseJsonToolResult( await getTool('kanban_get').execute({ claudeDir, teamName, }) ); expect(kanbanState.tasks[reviewTask.id]).toBeUndefined(); expect(JSON.stringify(kanbanState.columnOrder ?? {})).not.toContain(reviewTask.id); const restored = parseJsonToolResult( await getTool('task_restore').execute({ claudeDir, teamName, taskId: reviewTask.id, actor: 'lead', }) ); expect(restored.status).toBe('pending'); expect(restored.reviewState).toBe('none'); await expect( getTool('task_restore').execute({ claudeDir, teamName, taskId: reviewTask.id, actor: 'lead', }) ).rejects.toThrow('task_restore only restores deleted tasks'); }); it('only notifies the owner on review_approve when notifyOwner is explicit', async () => { const claudeDir = makeClaudeDir(); const teamName = 'review-approval-notify'; writeTeamConfig(claudeDir, teamName, { members: [ { name: 'lead', role: 'team-lead' }, { name: 'alice', role: 'reviewer' }, { name: 'bob', role: 'developer' }, ], }); const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'bob.json'); const readOwnerInbox = () => fs.existsSync(ownerInboxPath) ? JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8')) : []; const quietTask = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Quiet approval', owner: 'bob', }) ); await getTool('task_complete').execute({ claudeDir, teamName, taskId: quietTask.id, actor: 'bob', }); await getTool('review_request').execute({ claudeDir, teamName, taskId: quietTask.id, from: 'lead', reviewer: 'alice', }); const beforeQuietApprove = readOwnerInbox().length; parseJsonToolResult( await getTool('review_approve').execute({ claudeDir, teamName, taskId: quietTask.id, from: 'alice', }) ); expect(readOwnerInbox()).toHaveLength(beforeQuietApprove); const notifyingTask = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Notifying approval', owner: 'bob', }) ); await getTool('task_complete').execute({ claudeDir, teamName, taskId: notifyingTask.id, actor: 'bob', }); await getTool('review_request').execute({ claudeDir, teamName, taskId: notifyingTask.id, from: 'lead', reviewer: 'alice', }); const beforeNotifyingApprove = readOwnerInbox().length; parseJsonToolResult( await getTool('review_approve').execute({ claudeDir, teamName, taskId: notifyingTask.id, from: 'alice', notifyOwner: true, }) ); const afterNotifyingApprove = readOwnerInbox(); expect(afterNotifyingApprove).toHaveLength(beforeNotifyingApprove + 1); expect(afterNotifyingApprove.at(-1).text).toContain('approved'); }); it('persists full message metadata through message_send', async () => { const claudeDir = makeClaudeDir(); const teamName = 'gamma'; writeTeamConfig(claudeDir, teamName, { members: [ { name: 'lead', role: 'team-lead' }, { name: 'alice', role: 'developer' }, ], }); const sent = parseJsonToolResult( await getTool('message_send').execute({ claudeDir, teamName, to: 'alice', text: 'Check this', from: 'lead', summary: 'Metadata test', source: 'system_notification', relayOfMessageId: 'msg-original-1', leadSessionId: 'session-42', attachments: [{ id: 'att-1', filename: 'note.txt', mimeType: 'text/plain', size: 4 }], taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName }], }) ); expect(sent.deliveredToInbox).toBe(true); expect(sent.protocolInstruction).toContain('do not call message_send again'); const inboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'); const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); expect(rows[0].source).toBe('system_notification'); expect(rows[0].relayOfMessageId).toBe('msg-original-1'); expect(rows[0].leadSessionId).toBe('session-42'); expect(rows[0].attachments[0].filename).toBe('note.txt'); expect(rows[0].taskRefs).toEqual([{ taskId: 'task-1', displayId: 'abcd1234', teamName }]); }); it('uses forced app claude dir over model-supplied claudeDir when configured', async () => { const forcedClaudeDir = makeClaudeDir(); const wrongClaudeDir = makeClaudeDir(); const teamName = 'forced-root'; writeTeamConfig(forcedClaudeDir, teamName, { members: [ { name: 'lead', role: 'team-lead' }, { name: 'bob', role: 'developer' }, ], }); const previousForcedDir = process.env.AGENT_TEAMS_MCP_CLAUDE_DIR; process.env.AGENT_TEAMS_MCP_CLAUDE_DIR = forcedClaudeDir; try { const sent = parseJsonToolResult( await getTool('message_send').execute({ claudeDir: wrongClaudeDir, teamName, to: 'user', text: 'Forced root reply', from: 'bob', }) ); expect(sent.deliveredToInbox).toBe(true); const forcedInboxPath = path.join(forcedClaudeDir, 'teams', teamName, 'inboxes', 'user.json'); const wrongInboxPath = path.join(wrongClaudeDir, 'teams', teamName, 'inboxes', 'user.json'); expect(JSON.parse(fs.readFileSync(forcedInboxPath, 'utf8'))[0]).toMatchObject({ from: 'bob', to: 'user', text: 'Forced root reply', }); expect(fs.existsSync(wrongInboxPath)).toBe(false); } finally { if (previousForcedDir === undefined) { delete process.env.AGENT_TEAMS_MCP_CLAUDE_DIR; } else { process.env.AGENT_TEAMS_MCP_CLAUDE_DIR = previousForcedDir; } } }); it('rejects non-configured teams before MCP side-effect writes', async () => { const claudeDir = makeClaudeDir(); writeTeamConfig(claudeDir, 'real-team', { members: [{ name: 'lead', role: 'team-lead' }], }); await expect( getTool('message_send').execute({ claudeDir, teamName: 'typo-team', to: 'alice', text: 'Should not create inbox', }) ).rejects.toThrow('Unknown team "typo-team"'); await expect( getTool('process_register').execute({ claudeDir, teamName: 'typo-team', pid: process.pid, label: 'watcher', }) ).rejects.toThrow('Unknown team "typo-team"'); await expect( getTool('cross_team_send').execute({ claudeDir, teamName: 'typo-team', toTeam: 'real-team', text: 'Should not deliver', }) ).rejects.toThrow('Unknown team "typo-team"'); expect(fs.existsSync(path.join(claudeDir, 'teams', 'typo-team'))).toBe(false); expect(fs.existsSync(path.join(claudeDir, 'teams', 'real-team', 'inboxes', 'lead.json'))).toBe( false ); }); 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('task_create').parameters?.safeParse({ teamName: 'demo', claudeDir: '/tmp/demo', subject: 'Created by schema', createdBy: 'ui-fixer', }).success ).toBe(true); expect( getTool('process_register').parameters?.safeParse({ teamName: 'demo', pid: 0, label: '', }).success ).toBe(false); }); it('task_add_comment succeeds even when owner inbox write fails', async () => { const claudeDir = makeClaudeDir(); const teamName = 'resilience'; writeTeamConfig(claudeDir, teamName, { members: [ { name: 'lead', role: 'team-lead' }, { name: 'alice', role: 'developer' }, { name: 'bob', role: 'reviewer' }, ], }); const task = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Comment resilience test', owner: 'alice', notifyOwner: false, }) ); // Corrupt the inbox file to force notification failure const inboxDir = path.join(claudeDir, 'teams', teamName, 'inboxes'); fs.mkdirSync(inboxDir, { recursive: true }); fs.writeFileSync(path.join(inboxDir, 'alice.json'), 'BROKEN JSON'); const commented = parseJsonToolResult( await getTool('task_add_comment').execute({ claudeDir, teamName, taskId: task.id, text: 'Comment should persist despite broken inbox', from: 'bob', }) ); expect(commented.commentId).toBeTruthy(); // Verify the comment is actually persisted on the task const reloaded = parseJsonToolResult( await getTool('task_get').execute({ claudeDir, teamName, taskId: task.id, }) ); expect(reloaded.comments).toHaveLength(1); expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox'); }); it('write operations return slim task and task_list returns allowlisted inventory rows', async () => { expect(getTool('task_list').description).toContain( 'Use it to browse, filter, and drill into inventory, not as a primary working queue.' ); expect(getTool('task_list').description).toContain('Deleted tasks are excluded.'); expect(getTool('task_list').description).toContain('Defaults to 50 rows and caps at 200 rows'); const claudeDir = makeClaudeDir(); const teamName = 'slim-check'; fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead' }] }); const task = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Slim task test', owner: 'lead', notifyOwner: false, }) ); // task_create returns full task (read operation) expect(task.historyEvents).toBeDefined(); // Add a comment so commentCount > 0 const commented = parseJsonToolResult( await getTool('task_add_comment').execute({ claudeDir, teamName, taskId: task.id, text: 'test comment', from: 'lead', }) ); // task_add_comment: nested task should be slim expect(commented.commentId).toBeTruthy(); expect(commented.comment.text).toBe('test comment'); expect(commented.task.commentCount).toBe(1); expect(commented.task.comments).toBeUndefined(); expect(commented.task.historyEvents).toBeUndefined(); // task_start: returns slim task directly const started = parseJsonToolResult( await getTool('task_start').execute({ claudeDir, teamName, taskId: task.id, actor: 'lead', }) ); expect(started.status).toBe('in_progress'); expect(started.commentCount).toBe(1); expect(started.comments).toBeUndefined(); expect(started.historyEvents).toBeUndefined(); expect(started.workIntervals).toBeUndefined(); // task_complete: returns slim task directly const completed = parseJsonToolResult( await getTool('task_complete').execute({ claudeDir, teamName, taskId: task.id, actor: 'lead', }) ); expect(completed.status).toBe('completed'); expect(completed.comments).toBeUndefined(); // task_list: explicit inventory shape only const listed = parseJsonToolResult(await getTool('task_list').execute({ claudeDir, teamName })); const listedTask = listed.find((t: { id: string }) => t.id === task.id); expect(listedTask).toBeDefined(); expect(listedTask).toEqual({ id: task.id, displayId: task.displayId, subject: 'Slim task test', status: 'completed', owner: 'lead', reviewState: 'none', commentCount: 1, createdAt: expect.any(String), updatedAt: expect.any(String), }); expect(listedTask.description).toBeUndefined(); expect(listedTask.comments).toBeUndefined(); expect(listedTask.historyEvents).toBeUndefined(); expect(listedTask.workIntervals).toBeUndefined(); // task_get: still returns full task with comments const full = parseJsonToolResult( await getTool('task_get').execute({ claudeDir, teamName, taskId: task.id, }) ); expect(full.comments).toHaveLength(1); expect(full.historyEvents).toBeDefined(); }); describe('task_create_from_message', () => { function writeSentMessage( claudeDir: string, teamName: string, message: Record ) { const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json'); const teamDir = path.join(claudeDir, 'teams', teamName); fs.mkdirSync(teamDir, { recursive: true }); const existing = fs.existsSync(sentPath) ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) : []; existing.push(message); fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2)); } function writeInboxMessage( claudeDir: string, teamName: string, memberName: string, message: Record ) { const inboxDir = path.join(claudeDir, 'teams', teamName, 'inboxes'); fs.mkdirSync(inboxDir, { recursive: true }); const inboxPath = path.join(inboxDir, `${memberName}.json`); const existing = fs.existsSync(inboxPath) ? JSON.parse(fs.readFileSync(inboxPath, 'utf8')) : []; existing.push(message); fs.writeFileSync(inboxPath, JSON.stringify(existing, null, 2)); } it('creates a task from a valid user message with provenance', async () => { const claudeDir = makeClaudeDir(); const teamName = 'msg-team'; fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead', role: 'team-lead' }], }); const messageId = 'msg-user-001'; writeSentMessage(claudeDir, teamName, { messageId, from: 'user', to: 'team-lead', text: 'Please implement the login page', timestamp: '2026-03-15T10:00:00.000Z', source: 'user_sent', }); const created = parseJsonToolResult( await getTool('task_create_from_message').execute({ claudeDir, teamName, messageId, subject: 'Implement login page', owner: 'lead', }) ); expect(created.subject).toBe('Implement login page'); expect(created.owner).toBe('lead'); expect(created.sourceMessageId).toBe(messageId); expect(created.sourceMessage).toBeDefined(); expect(created.sourceMessage.text).toBe('Please implement the login page'); expect(created.sourceMessage.from).toBe('user'); expect(created.sourceMessage.timestamp).toBe('2026-03-15T10:00:00.000Z'); expect(created.sourceMessage.source).toBe('user_sent'); }); it('strips agent-only blocks from source text', async () => { const claudeDir = makeClaudeDir(); const teamName = 'strip-team'; fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead', role: 'team-lead' }], }); const messageId = 'msg-with-agent-blocks'; writeSentMessage(claudeDir, teamName, { messageId, from: 'user', text: 'Fix the bug \nuse task_create to track\n in the API', timestamp: '2026-03-15T11:00:00.000Z', source: 'user_sent', }); const created = parseJsonToolResult( await getTool('task_create_from_message').execute({ claudeDir, teamName, messageId, subject: 'Fix API bug', }) ); expect(created.sourceMessage.text).toBe('Fix the bug in the API'); expect(created.sourceMessage.text).not.toContain('info_for_agent'); }); it('rejects unknown messageId', async () => { const claudeDir = makeClaudeDir(); const teamName = 'unknown-msg'; fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead', role: 'team-lead' }], }); await expect( getTool('task_create_from_message').execute({ claudeDir, teamName, messageId: 'nonexistent-msg', subject: 'Should fail', }) ).rejects.toThrow( 'Message not found: nonexistent-msg. task_create_from_message only works with the explicit User MessageId' ); }); it('rejects non-user-originated message sources', async () => { const claudeDir = makeClaudeDir(); const teamName = 'source-reject'; fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead', role: 'team-lead' }], }); const messageId = 'msg-system-001'; writeSentMessage(claudeDir, teamName, { messageId, from: 'system', text: 'System generated notification', timestamp: '2026-03-15T12:00:00.000Z', source: 'system_notification', }); await expect( getTool('task_create_from_message').execute({ claudeDir, teamName, messageId, subject: 'Should fail', }) ).rejects.toThrow( 'task_create_from_message only accepts explicit user_sent messages from the relay prompt' ); }); it('rejects lead_process and cross_team sources explicitly', async () => { const claudeDir = makeClaudeDir(); const teamName = 'source-reject-2'; fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead', role: 'team-lead' }], }); writeSentMessage(claudeDir, teamName, { messageId: 'msg-lead-001', from: 'team-lead', text: 'Lead process message', timestamp: '2026-03-15T12:01:00.000Z', source: 'lead_process', }); writeSentMessage(claudeDir, teamName, { messageId: 'msg-cross-001', from: 'other-team.lead', text: 'Cross team message', timestamp: '2026-03-15T12:02:00.000Z', source: 'cross_team', }); await expect( getTool('task_create_from_message').execute({ claudeDir, teamName, messageId: 'msg-lead-001', subject: 'Should fail', }) ).rejects.toThrow( 'task_create_from_message only accepts explicit user_sent messages from the relay prompt' ); await expect( getTool('task_create_from_message').execute({ claudeDir, teamName, messageId: 'msg-cross-001', subject: 'Should fail', }) ).rejects.toThrow( 'task_create_from_message only accepts explicit user_sent messages from the relay prompt' ); }); it('rejects messages without an explicit source field (fail closed)', async () => { const claudeDir = makeClaudeDir(); const teamName = 'no-source'; fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead', role: 'team-lead' }], }); writeSentMessage(claudeDir, teamName, { messageId: 'msg-no-source', from: 'user', text: 'Old message without source field', timestamp: '2026-03-15T12:03:00.000Z', // no source field }); await expect( getTool('task_create_from_message').execute({ claudeDir, teamName, messageId: 'msg-no-source', subject: 'Should fail', }) ).rejects.toThrow( 'task_create_from_message only accepts explicit user_sent messages from the relay prompt' ); }); it('rejects relay copies', async () => { const claudeDir = makeClaudeDir(); const teamName = 'relay-reject'; fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead', role: 'team-lead' }], }); const messageId = 'msg-relay-001'; writeSentMessage(claudeDir, teamName, { messageId, from: 'user', text: 'Relayed content', timestamp: '2026-03-15T13:00:00.000Z', source: 'user_sent', relayOfMessageId: 'original-msg-999', }); await expect( getTool('task_create_from_message').execute({ claudeDir, teamName, messageId, subject: 'Should fail', }) ).rejects.toThrow( 'Cannot create task from a relay copy. Use the original user_sent message and its explicit User MessageId from the relay prompt instead.' ); }); it('preserves attachment metadata without blob copying', async () => { const claudeDir = makeClaudeDir(); const teamName = 'attach-meta'; fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead', role: 'team-lead' }], }); const messageId = 'msg-attach-001'; writeInboxMessage(claudeDir, teamName, 'lead', { messageId, from: 'user', to: 'lead', text: 'See attached screenshot', timestamp: '2026-03-15T14:00:00.000Z', source: 'user_sent', attachments: [ { id: 'att-1', filename: 'screenshot.png', mimeType: 'image/png', size: 42000 }, ], }); const created = parseJsonToolResult( await getTool('task_create_from_message').execute({ claudeDir, teamName, messageId, subject: 'Review screenshot', }) ); expect(created.sourceMessage.attachments).toHaveLength(1); expect(created.sourceMessage.attachments[0].id).toBe('att-1'); expect(created.sourceMessage.attachments[0].filename).toBe('screenshot.png'); expect(created.sourceMessage.attachments[0].mimeType).toBe('image/png'); expect(created.sourceMessage.attachments[0].size).toBe(42000); }); it('produces the same canonical task shape as task_create plus provenance', async () => { const claudeDir = makeClaudeDir(); const teamName = 'parity-check'; fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead', role: 'team-lead' }], }); const messageId = 'msg-parity-001'; writeSentMessage(claudeDir, teamName, { messageId, from: 'user', text: 'Build the dashboard', timestamp: '2026-03-15T15:00:00.000Z', source: 'user_sent', }); const fromMessage = parseJsonToolResult( await getTool('task_create_from_message').execute({ claudeDir, teamName, messageId, subject: 'Build dashboard', description: 'Create the main dashboard view', owner: 'lead', }) ); const regular = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Build dashboard (regular)', description: 'Create the main dashboard view', owner: 'lead', }) ); // Both have the same canonical shape expect(fromMessage.status).toBe(regular.status); expect(fromMessage.historyEvents).toHaveLength(regular.historyEvents.length); expect(typeof fromMessage.id).toBe(typeof regular.id); expect(typeof fromMessage.displayId).toBe(typeof regular.displayId); // Only the from_message task has provenance expect(fromMessage.sourceMessageId).toBe(messageId); expect(fromMessage.sourceMessage).toBeDefined(); expect(regular.sourceMessageId).toBeUndefined(); expect(regular.sourceMessage).toBeUndefined(); }); it('survives create → persist → read round-trip with provenance intact', async () => { const claudeDir = makeClaudeDir(); const teamName = 'roundtrip'; fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead', role: 'team-lead' }], }); const messageId = 'msg-roundtrip-001'; writeSentMessage(claudeDir, teamName, { messageId, from: 'user', text: 'Roundtrip test message', timestamp: '2026-03-15T16:00:00.000Z', source: 'user_sent', attachments: [{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }], }); const created = parseJsonToolResult( await getTool('task_create_from_message').execute({ claudeDir, teamName, messageId, subject: 'Roundtrip task', description: 'Test persistence', }) ); // Re-read from disk via task_get to verify persistence const reloaded = parseJsonToolResult( await getTool('task_get').execute({ claudeDir, teamName, taskId: created.id, }) ); expect(reloaded.sourceMessageId).toBe(messageId); expect(reloaded.sourceMessage).toBeDefined(); expect(reloaded.sourceMessage.text).toBe('Roundtrip test message'); expect(reloaded.sourceMessage.from).toBe('user'); expect(reloaded.sourceMessage.timestamp).toBe('2026-03-15T16:00:00.000Z'); expect(reloaded.sourceMessage.source).toBe('user_sent'); expect(reloaded.sourceMessage.attachments).toHaveLength(1); expect(reloaded.sourceMessage.attachments[0].id).toBe('att-rt'); }); it('old tasks without provenance continue to read normally', async () => { const claudeDir = makeClaudeDir(); const teamName = 'legacy'; fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead', role: 'team-lead' }], }); // Create a regular task (no provenance) const regular = parseJsonToolResult( await getTool('task_create').execute({ claudeDir, teamName, subject: 'Legacy task without provenance', }) ); // Re-read — should work without provenance fields const reloaded = parseJsonToolResult( await getTool('task_get').execute({ claudeDir, teamName, taskId: regular.id, }) ); expect(reloaded.subject).toBe('Legacy task without provenance'); expect(reloaded.sourceMessageId).toBeUndefined(); expect(reloaded.sourceMessage).toBeUndefined(); }); it('validates zod schema rejects missing required fields', () => { expect( getTool('task_create_from_message').parameters?.safeParse({ teamName: 'demo', messageId: 'msg-1', // subject is missing }).success ).toBe(false); expect( getTool('task_create_from_message').parameters?.safeParse({ teamName: 'demo', // messageId is missing subject: 'Test', }).success ).toBe(false); expect( getTool('task_create_from_message').parameters?.safeParse({ teamName: 'demo', messageId: 'msg-1', subject: 'Valid', }).success ).toBe(true); }); }); it('fails closed for task_create when team config does not exist', async () => { const claudeDir = makeClaudeDir(); await expect( getTool('task_create').execute({ claudeDir, teamName: 'team-lead', subject: 'Phantom task should fail', }) ).rejects.toThrow( 'Unknown team "team-lead". Board tools require an existing configured team with config.json.' ); }); it('fails closed for task_create_from_message when team config does not exist', async () => { const claudeDir = makeClaudeDir(); await expect( getTool('task_create_from_message').execute({ claudeDir, teamName: 'team-lead', messageId: 'msg-1', subject: 'Phantom task should fail', }) ).rejects.toThrow( 'Unknown team "team-lead". Board tools require an existing configured team with config.json.' ); }); it('fails closed for primary queue and inventory tools when team config does not exist', async () => { const claudeDir = makeClaudeDir(); const params = { claudeDir, teamName: 'team-lead' }; const expected = 'Unknown team "team-lead". Board tools require an existing configured team with config.json.'; await expect(getTool('lead_briefing').execute(params)).rejects.toThrow(expected); await expect( getTool('task_briefing').execute({ ...params, memberName: 'alice', }) ).rejects.toThrow(expected); await expect(getTool('task_list').execute(params)).rejects.toThrow(expected); }); });