1782 lines
58 KiB
TypeScript
1782 lines
58 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
function parseJsonToolResult(result: unknown) {
|
|
const response = result as {
|
|
content?: Array<{ text?: string }>;
|
|
isError?: boolean;
|
|
};
|
|
const text = response.content?.[0]?.text;
|
|
if (response.isError) {
|
|
throw new Error(text ?? 'Tool returned an unspecified error');
|
|
}
|
|
return JSON.parse(text ?? 'null');
|
|
}
|
|
|
|
async function writeTeamConfig(claudeDir: string, teamName: string) {
|
|
const teamDir = path.join(claudeDir, 'teams', teamName);
|
|
await mkdir(teamDir, { recursive: true });
|
|
await writeFile(
|
|
path.join(teamDir, 'config.json'),
|
|
JSON.stringify(
|
|
{
|
|
name: teamName,
|
|
members: [
|
|
{ name: 'team-lead', agentType: 'team-lead' },
|
|
{ name: 'alice', agentType: 'teammate', role: 'developer' },
|
|
{ name: 'bob', agentType: 'teammate', role: 'reviewer' },
|
|
],
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
async function writeBulkTaskRows(claudeDir: string, teamName: string, count: number) {
|
|
const tasksDir = path.join(claudeDir, 'tasks', teamName);
|
|
await mkdir(tasksDir, { recursive: true });
|
|
|
|
await Promise.all(
|
|
Array.from({ length: count }, async (_, index) => {
|
|
const ordinal = String(index + 1).padStart(3, '0');
|
|
const id = `bulk-${ordinal}`;
|
|
const timestamp = new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString();
|
|
await writeFile(
|
|
path.join(tasksDir, `${id}.json`),
|
|
JSON.stringify(
|
|
{
|
|
id,
|
|
displayId: id,
|
|
subject: `Bulk inventory task ${ordinal}`,
|
|
description: `Large description that must not be returned in task_list row ${ordinal}`,
|
|
owner: index % 2 === 0 ? 'alice' : 'bob',
|
|
status: index % 3 === 0 ? 'completed' : 'pending',
|
|
reviewState: 'none',
|
|
commentCount: 99,
|
|
comments: [
|
|
{
|
|
id: `comment-${ordinal}`,
|
|
author: 'alice',
|
|
text: 'Large comment that must not be returned in task_list rows',
|
|
},
|
|
],
|
|
historyEvents: [{ type: 'task_created', status: 'pending', timestamp }],
|
|
workIntervals: [{ startedAt: timestamp }],
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
'utf8'
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
async function writeInventoryTaskRow(
|
|
claudeDir: string,
|
|
teamName: string,
|
|
task: {
|
|
id: string;
|
|
owner: string;
|
|
subject: string;
|
|
status?: 'pending' | 'in_progress' | 'completed' | 'deleted';
|
|
createdAt: string;
|
|
}
|
|
) {
|
|
const tasksDir = path.join(claudeDir, 'tasks', teamName);
|
|
await mkdir(tasksDir, { recursive: true });
|
|
await writeFile(
|
|
path.join(tasksDir, `${task.id}.json`),
|
|
JSON.stringify(
|
|
{
|
|
id: task.id,
|
|
displayId: task.id,
|
|
subject: task.subject,
|
|
description: `Drill-down description for ${task.subject}`,
|
|
owner: task.owner,
|
|
status: task.status ?? 'pending',
|
|
reviewState: 'none',
|
|
comments: [],
|
|
historyEvents: [{ type: 'task_created', status: task.status ?? 'pending', timestamp: task.createdAt }],
|
|
createdAt: task.createdAt,
|
|
updatedAt: task.createdAt,
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
class McpStdIoClient {
|
|
private readonly child: ChildProcessWithoutNullStreams;
|
|
private stdoutBuffer = '';
|
|
|
|
constructor(serverPath: string, cwd: string) {
|
|
this.child = spawn('node', [serverPath], {
|
|
cwd,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
|
|
this.child.stdout.setEncoding('utf8');
|
|
this.child.stdout.on('data', (chunk: string) => {
|
|
this.stdoutBuffer += chunk;
|
|
});
|
|
}
|
|
|
|
async initialize() {
|
|
const response = await this.request(1, 'initialize', {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'vitest-e2e', version: '1.0.0' },
|
|
});
|
|
|
|
this.notify('notifications/initialized');
|
|
return response;
|
|
}
|
|
|
|
async listTools() {
|
|
return this.request(2, 'tools/list', {});
|
|
}
|
|
|
|
async callTool(name: string, args: Record<string, unknown>, id = 3) {
|
|
return this.request(id, 'tools/call', { name, arguments: args });
|
|
}
|
|
|
|
async close() {
|
|
this.child.kill('SIGTERM');
|
|
await new Promise<void>((resolve) => {
|
|
this.child.once('exit', () => resolve());
|
|
setTimeout(() => resolve(), 1000).unref();
|
|
});
|
|
}
|
|
|
|
private notify(method: string, params?: Record<string, unknown>) {
|
|
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params ? { params } : {}) })}\n`);
|
|
}
|
|
|
|
private async request(id: number, method: string, params: Record<string, unknown>) {
|
|
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
|
|
return this.readMessage(id);
|
|
}
|
|
|
|
private async readMessage(expectedId: number) {
|
|
const deadline = Date.now() + 15000;
|
|
|
|
while (Date.now() < deadline) {
|
|
const newlineIndex = this.stdoutBuffer.indexOf('\n');
|
|
if (newlineIndex !== -1) {
|
|
const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
|
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
|
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
const parsed = JSON.parse(line) as { id?: number };
|
|
if (parsed.id === expectedId) {
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for MCP response ${expectedId}`);
|
|
}
|
|
}
|
|
|
|
describe('agent-teams-mcp stdio e2e', () => {
|
|
const serverPath = fileURLToPath(new URL('../dist/index.js', import.meta.url));
|
|
const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url));
|
|
|
|
let claudeDir: string;
|
|
|
|
beforeEach(async () => {
|
|
claudeDir = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-e2e-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(claudeDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('boots over stdio, lists task tools, and executes task lifecycle calls', async () => {
|
|
await writeTeamConfig(claudeDir, 'e2e-team');
|
|
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
|
|
|
try {
|
|
const init = await client.initialize();
|
|
expect(init).toHaveProperty('result');
|
|
|
|
const tools = (await client.listTools()) as {
|
|
result?: { tools?: Array<{ name: string; description?: string }> };
|
|
};
|
|
const registeredTools = tools.result?.tools ?? [];
|
|
const toolNames = registeredTools.map((tool) => tool.name);
|
|
const taskListTool = registeredTools.find((tool) => tool.name === 'task_list');
|
|
|
|
expect(toolNames).toContain('task_create');
|
|
expect(toolNames).toContain('task_start');
|
|
expect(toolNames).toContain('task_briefing');
|
|
expect(toolNames).toContain('member_briefing');
|
|
expect(toolNames).toContain('review_approve');
|
|
expect(toolNames).toContain('lead_briefing');
|
|
expect(taskListTool?.description).toContain(
|
|
'Use it to browse, filter, and drill into inventory, not as a primary working queue.'
|
|
);
|
|
expect(taskListTool?.description).toContain('Deleted tasks are excluded.');
|
|
|
|
const createResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
subject: 'Smoke task',
|
|
owner: 'alice',
|
|
description: 'Smoke task description',
|
|
},
|
|
3
|
|
);
|
|
const createdTask = parseJsonToolResult((createResult as { result: unknown }).result);
|
|
|
|
expect(createdTask.subject).toBe('Smoke task');
|
|
expect(createdTask.owner).toBe('alice');
|
|
expect(typeof createdTask.id).toBe('string');
|
|
|
|
const startResult = await client.callTool(
|
|
'task_start',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
taskId: createdTask.id,
|
|
actor: 'alice',
|
|
},
|
|
4
|
|
);
|
|
const startedTask = parseJsonToolResult((startResult as { result: unknown }).result);
|
|
|
|
expect(startedTask.status).toBe('in_progress');
|
|
expect(startedTask.id).toBe(createdTask.id);
|
|
|
|
const commentResult = await client.callTool(
|
|
'task_add_comment',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
taskId: createdTask.id,
|
|
text: 'Working through the smoke task.',
|
|
from: 'alice',
|
|
},
|
|
5
|
|
);
|
|
const commentPayload = parseJsonToolResult((commentResult as { result: unknown }).result);
|
|
expect(commentPayload.task.id).toBe(createdTask.id);
|
|
expect(commentPayload.comment.text).toBe('Working through the smoke task.');
|
|
|
|
const reviewCreateResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
subject: 'Review task',
|
|
owner: 'alice',
|
|
},
|
|
6
|
|
);
|
|
const reviewTask = parseJsonToolResult((reviewCreateResult as { result: unknown }).result);
|
|
|
|
const completeResult = await client.callTool(
|
|
'task_complete',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
taskId: reviewTask.id,
|
|
actor: 'alice',
|
|
},
|
|
7
|
|
);
|
|
const completedTask = parseJsonToolResult((completeResult as { result: unknown }).result);
|
|
expect(completedTask.status).toBe('completed');
|
|
|
|
const reviewRequestResult = await client.callTool(
|
|
'review_request',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
taskId: reviewTask.id,
|
|
from: 'team-lead',
|
|
reviewer: 'bob',
|
|
},
|
|
8
|
|
);
|
|
const reviewRequestedTask = parseJsonToolResult(
|
|
(reviewRequestResult as { result: unknown }).result
|
|
);
|
|
expect(reviewRequestedTask.reviewState).toBe('review');
|
|
|
|
const unassignedCreateResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
subject: 'Needs owner assignment',
|
|
},
|
|
9
|
|
);
|
|
const unassignedTask = parseJsonToolResult(
|
|
(unassignedCreateResult as { result: unknown }).result
|
|
);
|
|
expect(unassignedTask.owner).toBeUndefined();
|
|
|
|
const taskBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
memberName: 'alice',
|
|
},
|
|
10
|
|
);
|
|
const taskBriefingText = (
|
|
((taskBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(taskBriefingText).toContain('Task briefing for alice:');
|
|
expect(taskBriefingText).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(taskBriefingText).toContain(
|
|
'Use task_list only to search/browse inventory rows, not as your working queue.'
|
|
);
|
|
expect(taskBriefingText).toContain('Actionable:');
|
|
expect(taskBriefingText).toContain(`#${createdTask.displayId}`);
|
|
expect(taskBriefingText).toContain('reason=owner_executing');
|
|
expect(taskBriefingText).toContain('Description: Smoke task description');
|
|
expect(taskBriefingText).toContain('Working through the smoke task.');
|
|
expect(taskBriefingText).toContain('Awareness:');
|
|
expect(taskBriefingText).toContain(`#${reviewTask.displayId}`);
|
|
|
|
const memberBriefingResult = await client.callTool(
|
|
'member_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
memberName: 'alice',
|
|
},
|
|
11
|
|
);
|
|
const memberBriefingText = (
|
|
((memberBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
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(
|
|
'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.'
|
|
);
|
|
|
|
const reviewerTaskBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
memberName: 'bob',
|
|
},
|
|
12
|
|
);
|
|
const reviewerTaskBriefingText = (
|
|
((reviewerTaskBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(reviewerTaskBriefingText).toContain('Task briefing for bob:');
|
|
expect(reviewerTaskBriefingText).toContain('Actionable:');
|
|
expect(reviewerTaskBriefingText).toContain(`#${reviewTask.displayId}`);
|
|
expect(reviewerTaskBriefingText).toContain('reviewer=bob');
|
|
expect(reviewerTaskBriefingText).toContain('reason=review_requested_waiting_pickup');
|
|
|
|
const leadBriefingResult = await client.callTool(
|
|
'lead_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
},
|
|
13
|
|
);
|
|
const leadBriefingText = (
|
|
((leadBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(leadBriefingText).toContain('Lead queue for team-lead on team "e2e-team":');
|
|
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('reason=owner_missing');
|
|
expect(leadBriefingText).toContain('Watching:');
|
|
expect(leadBriefingText).toContain(`#${reviewTask.displayId}`);
|
|
|
|
const inventoryResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
},
|
|
14
|
|
);
|
|
const inventoryRows = parseJsonToolResult((inventoryResult as { result: unknown }).result);
|
|
const reviewInventoryRow = inventoryRows.find(
|
|
(row: { id: string }) => row.id === reviewTask.id
|
|
) as Record<string, unknown> | undefined;
|
|
const unassignedInventoryRow = inventoryRows.find(
|
|
(row: { id: string }) => row.id === unassignedTask.id
|
|
) as Record<string, unknown> | undefined;
|
|
expect(reviewInventoryRow).toMatchObject({
|
|
id: reviewTask.id,
|
|
subject: 'Review task',
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
reviewState: 'review',
|
|
});
|
|
expect(reviewInventoryRow?.description).toBeUndefined();
|
|
expect(reviewInventoryRow?.comments).toBeUndefined();
|
|
expect(reviewInventoryRow?.historyEvents).toBeUndefined();
|
|
expect(reviewInventoryRow?.workIntervals).toBeUndefined();
|
|
expect(unassignedInventoryRow).toMatchObject({
|
|
id: unassignedTask.id,
|
|
subject: 'Needs owner assignment',
|
|
status: 'pending',
|
|
reviewState: 'none',
|
|
});
|
|
expect(unassignedInventoryRow?.owner).toBeUndefined();
|
|
|
|
const filteredListResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'e2e-team',
|
|
reviewState: 'review',
|
|
kanbanColumn: 'review',
|
|
},
|
|
15
|
|
);
|
|
const filteredTasks = parseJsonToolResult((filteredListResult as { result: unknown }).result);
|
|
expect(filteredTasks).toHaveLength(1);
|
|
expect(filteredTasks[0]).toMatchObject({
|
|
id: reviewTask.id,
|
|
status: 'completed',
|
|
reviewState: 'review',
|
|
owner: 'alice',
|
|
});
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
});
|
|
|
|
it('caps high-volume task_list inventory over stdio and keeps rows compact', async () => {
|
|
await writeTeamConfig(claudeDir, 'bulk-inventory-team');
|
|
await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225);
|
|
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
|
|
|
try {
|
|
await client.initialize();
|
|
|
|
const tools = (await client.listTools()) as {
|
|
result?: { tools?: Array<{ name: string; description?: string }> };
|
|
};
|
|
const taskListTool = tools.result?.tools?.find((tool) => tool.name === 'task_list');
|
|
expect(taskListTool?.description).toContain('Defaults to 50 rows and caps at 200 rows');
|
|
|
|
const defaultInventoryResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'bulk-inventory-team',
|
|
},
|
|
21
|
|
);
|
|
const defaultRows = parseJsonToolResult(
|
|
(defaultInventoryResult as { result: unknown }).result
|
|
) as Array<Record<string, unknown>>;
|
|
expect(defaultRows).toHaveLength(50);
|
|
for (const row of defaultRows) {
|
|
expect(row.description).toBeUndefined();
|
|
expect(row.comments).toBeUndefined();
|
|
expect(row.historyEvents).toBeUndefined();
|
|
expect(row.workIntervals).toBeUndefined();
|
|
expect(row.commentCount).toBe(1);
|
|
}
|
|
|
|
const drillDownResult = await client.callTool(
|
|
'task_get',
|
|
{
|
|
claudeDir,
|
|
teamName: 'bulk-inventory-team',
|
|
taskId: defaultRows[0].id,
|
|
},
|
|
21_1
|
|
);
|
|
const drillDownTask = parseJsonToolResult(
|
|
(drillDownResult as { result: unknown }).result
|
|
) as Record<string, unknown>;
|
|
expect(drillDownTask.id).toBe(defaultRows[0].id);
|
|
expect(drillDownTask.description).toContain('Large description that must not be returned');
|
|
expect(drillDownTask.comments).toHaveLength(1);
|
|
expect(drillDownTask.historyEvents).toHaveLength(1);
|
|
expect(drillDownTask.workIntervals).toHaveLength(1);
|
|
|
|
const smallLimitResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'bulk-inventory-team',
|
|
limit: 7,
|
|
},
|
|
22
|
|
);
|
|
const smallLimitRows = parseJsonToolResult(
|
|
(smallLimitResult as { result: unknown }).result
|
|
) as Array<Record<string, unknown>>;
|
|
expect(smallLimitRows).toHaveLength(7);
|
|
|
|
const filteredLimitResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'bulk-inventory-team',
|
|
owner: 'bob',
|
|
limit: 5,
|
|
},
|
|
23
|
|
);
|
|
const filteredLimitRows = parseJsonToolResult(
|
|
(filteredLimitResult as { result: unknown }).result
|
|
) as Array<Record<string, unknown>>;
|
|
expect(filteredLimitRows).toHaveLength(5);
|
|
expect(filteredLimitRows.every((row) => row.owner === 'bob')).toBe(true);
|
|
|
|
const overLimitResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'bulk-inventory-team',
|
|
limit: 999,
|
|
},
|
|
24
|
|
);
|
|
const overLimitRows = parseJsonToolResult(
|
|
(overLimitResult as { result: unknown }).result
|
|
) as Array<Record<string, unknown>>;
|
|
expect(overLimitRows).toHaveLength(200);
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
});
|
|
|
|
it('applies task_list filters before default caps over stdio', async () => {
|
|
await writeTeamConfig(claudeDir, 'filter-before-cap-team');
|
|
|
|
for (let index = 0; index < 60; index += 1) {
|
|
const ordinal = String(index + 1).padStart(3, '0');
|
|
await writeInventoryTaskRow(claudeDir, 'filter-before-cap-team', {
|
|
id: `new-alice-${ordinal}`,
|
|
owner: 'alice',
|
|
subject: `New alice task ${ordinal}`,
|
|
createdAt: new Date(Date.UTC(2026, 0, 2, 0, 0, index)).toISOString(),
|
|
});
|
|
}
|
|
|
|
for (let index = 0; index < 3; index += 1) {
|
|
const ordinal = String(index + 1).padStart(3, '0');
|
|
await writeInventoryTaskRow(claudeDir, 'filter-before-cap-team', {
|
|
id: `old-bob-${ordinal}`,
|
|
owner: 'bob',
|
|
subject: `Old bob task ${ordinal}`,
|
|
createdAt: new Date(Date.UTC(2025, 11, 31, 0, 0, index)).toISOString(),
|
|
});
|
|
}
|
|
|
|
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
|
|
|
try {
|
|
await client.initialize();
|
|
|
|
const defaultInventoryResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'filter-before-cap-team',
|
|
},
|
|
31
|
|
);
|
|
const defaultRows = parseJsonToolResult(
|
|
(defaultInventoryResult as { result: unknown }).result
|
|
) as Array<Record<string, unknown>>;
|
|
expect(defaultRows).toHaveLength(50);
|
|
expect(defaultRows.some((row) => row.owner === 'bob')).toBe(false);
|
|
|
|
const filteredInventoryResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'filter-before-cap-team',
|
|
owner: 'bob',
|
|
},
|
|
32
|
|
);
|
|
const filteredRows = parseJsonToolResult(
|
|
(filteredInventoryResult as { result: unknown }).result
|
|
) as Array<Record<string, unknown>>;
|
|
expect(filteredRows).toHaveLength(3);
|
|
expect(filteredRows.map((row) => row.id).sort()).toEqual([
|
|
'old-bob-001',
|
|
'old-bob-002',
|
|
'old-bob-003',
|
|
]);
|
|
expect(filteredRows.every((row) => row.owner === 'bob')).toBe(true);
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
});
|
|
|
|
it('keeps task_list as active inventory and leaves deleted drill-down to task_get over stdio', async () => {
|
|
await writeTeamConfig(claudeDir, 'deleted-inventory-team');
|
|
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
|
|
|
try {
|
|
await client.initialize();
|
|
|
|
const tools = (await client.listTools()) as {
|
|
result?: { tools?: Array<{ name: string; description?: string; inputSchema?: unknown }> };
|
|
};
|
|
const taskListTool = tools.result?.tools?.find((tool) => tool.name === 'task_list');
|
|
expect(taskListTool?.description).toContain('Deleted tasks are excluded.');
|
|
expect(JSON.stringify(taskListTool?.inputSchema)).toContain('"pending"');
|
|
expect(JSON.stringify(taskListTool?.inputSchema)).toContain('"in_progress"');
|
|
expect(JSON.stringify(taskListTool?.inputSchema)).toContain('"completed"');
|
|
expect(JSON.stringify(taskListTool?.inputSchema)).not.toContain('"deleted"');
|
|
|
|
const deletedStatusListResult = (await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'deleted-inventory-team',
|
|
status: 'deleted',
|
|
},
|
|
40
|
|
)) as { error?: { code?: number; message?: string } };
|
|
expect(deletedStatusListResult.error?.code).toBe(-32602);
|
|
expect(deletedStatusListResult.error?.message).toContain(
|
|
'expected one of "pending"|"in_progress"|"completed"'
|
|
);
|
|
|
|
const createResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'deleted-inventory-team',
|
|
subject: 'Deleted task should not be inventory',
|
|
owner: 'alice',
|
|
},
|
|
41
|
|
);
|
|
const task = parseJsonToolResult((createResult as { result: unknown }).result);
|
|
|
|
await client.callTool(
|
|
'task_set_status',
|
|
{
|
|
claudeDir,
|
|
teamName: 'deleted-inventory-team',
|
|
taskId: task.id,
|
|
status: 'deleted',
|
|
actor: 'alice',
|
|
},
|
|
42
|
|
);
|
|
|
|
const inventoryResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'deleted-inventory-team',
|
|
owner: 'alice',
|
|
},
|
|
43
|
|
);
|
|
const inventoryRows = parseJsonToolResult(
|
|
(inventoryResult as { result: unknown }).result
|
|
) as Array<Record<string, unknown>>;
|
|
expect(inventoryRows.find((row) => row.id === task.id)).toBeUndefined();
|
|
|
|
const drillDownResult = await client.callTool(
|
|
'task_get',
|
|
{
|
|
claudeDir,
|
|
teamName: 'deleted-inventory-team',
|
|
taskId: task.id,
|
|
},
|
|
44
|
|
);
|
|
const drillDownTask = parseJsonToolResult(
|
|
(drillDownResult as { result: unknown }).result
|
|
) as Record<string, unknown>;
|
|
expect(drillDownTask.id).toBe(task.id);
|
|
expect(drillDownTask.status).toBe('deleted');
|
|
expect(drillDownTask.deletedAt).toEqual(expect.any(String));
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
});
|
|
|
|
it('preserves legacy kanban reviewer fallback over stdio for old boards without review history reviewer', async () => {
|
|
await writeTeamConfig(claudeDir, 'legacy-review-team');
|
|
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
|
|
|
try {
|
|
await client.initialize();
|
|
|
|
const createResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'legacy-review-team',
|
|
subject: 'Legacy review fallback',
|
|
owner: 'alice',
|
|
},
|
|
21
|
|
);
|
|
const createdTask = parseJsonToolResult((createResult as { result: unknown }).result);
|
|
|
|
const completeResult = await client.callTool(
|
|
'task_complete',
|
|
{
|
|
claudeDir,
|
|
teamName: 'legacy-review-team',
|
|
taskId: createdTask.id,
|
|
actor: 'alice',
|
|
},
|
|
22
|
|
);
|
|
const completedTask = parseJsonToolResult((completeResult as { result: unknown }).result);
|
|
expect(completedTask.status).toBe('completed');
|
|
|
|
const taskPath = path.join(claudeDir, 'tasks', 'legacy-review-team', `${createdTask.id}.json`);
|
|
const persistedTask = JSON.parse(await readFile(taskPath, 'utf8')) as {
|
|
reviewState?: string;
|
|
historyEvents?: Array<Record<string, unknown>>;
|
|
};
|
|
persistedTask.reviewState = 'review';
|
|
persistedTask.historyEvents = (Array.isArray(persistedTask.historyEvents)
|
|
? persistedTask.historyEvents
|
|
: []
|
|
).filter(
|
|
(event) =>
|
|
event.type !== 'review_requested' &&
|
|
event.type !== 'review_started' &&
|
|
event.type !== 'review_approved' &&
|
|
event.type !== 'review_changes_requested'
|
|
);
|
|
await writeFile(taskPath, JSON.stringify(persistedTask, null, 2), 'utf8');
|
|
|
|
const kanbanPath = path.join(claudeDir, 'teams', 'legacy-review-team', 'kanban-state.json');
|
|
await writeFile(
|
|
kanbanPath,
|
|
JSON.stringify(
|
|
{
|
|
teamName: 'legacy-review-team',
|
|
reviewers: [],
|
|
tasks: {
|
|
[createdTask.id]: {
|
|
column: 'review',
|
|
reviewer: 'bob',
|
|
movedAt: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
},
|
|
columnOrder: {
|
|
review: [createdTask.id],
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
'utf8'
|
|
);
|
|
|
|
const reviewerBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'legacy-review-team',
|
|
memberName: 'bob',
|
|
},
|
|
23
|
|
);
|
|
const reviewerBriefingText = (
|
|
((reviewerBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(reviewerBriefingText).toContain('Task briefing for bob:');
|
|
expect(reviewerBriefingText).toContain('Actionable:');
|
|
expect(reviewerBriefingText).toContain(`#${createdTask.displayId}`);
|
|
expect(reviewerBriefingText).toContain('reviewer=bob');
|
|
expect(reviewerBriefingText).not.toContain('review_reviewer_missing');
|
|
|
|
const leadBriefingResult = await client.callTool(
|
|
'lead_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'legacy-review-team',
|
|
},
|
|
24
|
|
);
|
|
const leadBriefingText = (
|
|
((leadBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(leadBriefingText).toContain('Lead queue for team-lead on team "legacy-review-team":');
|
|
expect(leadBriefingText).toContain('Watching:');
|
|
expect(leadBriefingText).toContain(`#${createdTask.displayId}`);
|
|
expect(leadBriefingText).not.toContain('review_reviewer_missing');
|
|
|
|
const inventoryResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'legacy-review-team',
|
|
reviewState: 'review',
|
|
kanbanColumn: 'review',
|
|
},
|
|
25
|
|
);
|
|
const inventoryRows = parseJsonToolResult((inventoryResult as { result: unknown }).result);
|
|
expect(inventoryRows).toHaveLength(1);
|
|
expect(inventoryRows[0]).toMatchObject({
|
|
id: createdTask.id,
|
|
owner: 'alice',
|
|
reviewState: 'review',
|
|
status: 'completed',
|
|
});
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
});
|
|
|
|
it('surfaces reviewer-assignment gaps and needs-fix review roundtrip over stdio', async () => {
|
|
await writeTeamConfig(claudeDir, 'review-roundtrip-team');
|
|
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
|
|
|
try {
|
|
await client.initialize();
|
|
|
|
const noReviewerCreateResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
subject: 'Needs reviewer assignment',
|
|
owner: 'alice',
|
|
},
|
|
31
|
|
);
|
|
const noReviewerTask = parseJsonToolResult(
|
|
(noReviewerCreateResult as { result: unknown }).result
|
|
);
|
|
|
|
await client.callTool(
|
|
'task_complete',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
taskId: noReviewerTask.id,
|
|
actor: 'alice',
|
|
},
|
|
32
|
|
);
|
|
|
|
const noReviewerRequestResult = await client.callTool(
|
|
'review_request',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
taskId: noReviewerTask.id,
|
|
from: 'team-lead',
|
|
},
|
|
33
|
|
);
|
|
const noReviewerRequestedTask = parseJsonToolResult(
|
|
(noReviewerRequestResult as { result: unknown }).result
|
|
);
|
|
expect(noReviewerRequestedTask.reviewState).toBe('review');
|
|
|
|
const leadAssignmentBriefingResult = await client.callTool(
|
|
'lead_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
},
|
|
34
|
|
);
|
|
const leadAssignmentBriefingText = (
|
|
((leadAssignmentBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(leadAssignmentBriefingText).toContain('Needs reviewer assignment:');
|
|
expect(leadAssignmentBriefingText).toContain(`#${noReviewerTask.displayId}`);
|
|
expect(leadAssignmentBriefingText).toContain('reason=review_reviewer_missing');
|
|
|
|
const reviewerEmptyBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
memberName: 'bob',
|
|
},
|
|
35
|
|
);
|
|
const reviewerEmptyBriefingText = (
|
|
((reviewerEmptyBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(reviewerEmptyBriefingText).toContain('No actionable or awareness tasks for bob.');
|
|
expect(reviewerEmptyBriefingText).not.toContain(`#${noReviewerTask.displayId}`);
|
|
|
|
const roundtripCreateResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
subject: 'Needs fixes after review',
|
|
owner: 'alice',
|
|
description: 'Roundtrip description',
|
|
},
|
|
36
|
|
);
|
|
const roundtripTask = parseJsonToolResult((roundtripCreateResult as { result: unknown }).result);
|
|
|
|
await client.callTool(
|
|
'task_complete',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
taskId: roundtripTask.id,
|
|
actor: 'alice',
|
|
},
|
|
37
|
|
);
|
|
|
|
await client.callTool(
|
|
'review_request',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
taskId: roundtripTask.id,
|
|
from: 'team-lead',
|
|
reviewer: 'bob',
|
|
},
|
|
38
|
|
);
|
|
|
|
await client.callTool(
|
|
'review_start',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
taskId: roundtripTask.id,
|
|
from: 'bob',
|
|
},
|
|
39
|
|
);
|
|
|
|
const reviewerActiveBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
memberName: 'bob',
|
|
},
|
|
40
|
|
);
|
|
const reviewerActiveBriefingText = (
|
|
((reviewerActiveBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(reviewerActiveBriefingText).toContain(`#${roundtripTask.displayId}`);
|
|
expect(reviewerActiveBriefingText).toContain('reason=review_in_progress');
|
|
expect(reviewerActiveBriefingText).toContain('reviewer=bob');
|
|
|
|
const changesResult = await client.callTool(
|
|
'review_request_changes',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
taskId: roundtripTask.id,
|
|
from: 'bob',
|
|
comment: 'Please fix the failing edge case.',
|
|
},
|
|
41
|
|
);
|
|
const changedTask = parseJsonToolResult((changesResult as { result: unknown }).result);
|
|
expect(changedTask.status).toBe('pending');
|
|
expect(changedTask.reviewState).toBe('needsFix');
|
|
|
|
const ownerNeedsFixBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
memberName: 'alice',
|
|
},
|
|
42
|
|
);
|
|
const ownerNeedsFixBriefingText = (
|
|
((ownerNeedsFixBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(ownerNeedsFixBriefingText).toContain(`#${roundtripTask.displayId}`);
|
|
expect(ownerNeedsFixBriefingText).toContain('Actionable:');
|
|
expect(ownerNeedsFixBriefingText).toContain('reason=needs_fix');
|
|
expect(ownerNeedsFixBriefingText).toContain('Description: Roundtrip description');
|
|
expect(ownerNeedsFixBriefingText).toContain('Please fix the failing edge case.');
|
|
|
|
const needsFixInventoryResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
owner: 'alice',
|
|
reviewState: 'needsFix',
|
|
status: 'pending',
|
|
},
|
|
43
|
|
);
|
|
const needsFixInventoryRows = parseJsonToolResult(
|
|
(needsFixInventoryResult as { result: unknown }).result
|
|
);
|
|
expect(needsFixInventoryRows).toHaveLength(1);
|
|
expect(needsFixInventoryRows[0]).toMatchObject({
|
|
id: roundtripTask.id,
|
|
owner: 'alice',
|
|
reviewState: 'needsFix',
|
|
status: 'pending',
|
|
});
|
|
|
|
const finalLeadBriefingResult = await client.callTool(
|
|
'lead_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'review-roundtrip-team',
|
|
},
|
|
44
|
|
);
|
|
const finalLeadBriefingText = (
|
|
((finalLeadBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(finalLeadBriefingText).toContain(`#${noReviewerTask.displayId}`);
|
|
expect(finalLeadBriefingText).not.toContain(`#${roundtripTask.displayId}`);
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
});
|
|
|
|
it('surfaces self-review invalid as lead-owned and supports relationship inventory filters over stdio', async () => {
|
|
await writeTeamConfig(claudeDir, 'inventory-filters-team');
|
|
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
|
|
|
try {
|
|
await client.initialize();
|
|
|
|
const baseCreateResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
subject: 'Base task',
|
|
owner: 'alice',
|
|
},
|
|
51
|
|
);
|
|
const baseTask = parseJsonToolResult((baseCreateResult as { result: unknown }).result);
|
|
|
|
const blockedCreateResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
subject: 'Blocked task',
|
|
owner: 'alice',
|
|
blockedBy: [baseTask.id],
|
|
},
|
|
52
|
|
);
|
|
const blockedTask = parseJsonToolResult((blockedCreateResult as { result: unknown }).result);
|
|
|
|
const relatedCreateResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
subject: 'Related task',
|
|
owner: 'alice',
|
|
related: [baseTask.id],
|
|
},
|
|
53
|
|
);
|
|
const relatedTask = parseJsonToolResult((relatedCreateResult as { result: unknown }).result);
|
|
|
|
const blockedInventoryResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
blockedBy: `#${baseTask.displayId}`,
|
|
},
|
|
54
|
|
);
|
|
const blockedInventoryRows = parseJsonToolResult(
|
|
(blockedInventoryResult as { result: unknown }).result
|
|
);
|
|
expect(blockedInventoryRows).toHaveLength(1);
|
|
expect(blockedInventoryRows[0]).toMatchObject({
|
|
id: blockedTask.id,
|
|
subject: 'Blocked task',
|
|
blockedBy: [baseTask.id],
|
|
});
|
|
|
|
const relatedInventoryResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
relatedTo: `#${baseTask.displayId}`,
|
|
},
|
|
55
|
|
);
|
|
const relatedInventoryRows = parseJsonToolResult(
|
|
(relatedInventoryResult as { result: unknown }).result
|
|
);
|
|
expect(relatedInventoryRows).toHaveLength(1);
|
|
expect(relatedInventoryRows[0]).toMatchObject({
|
|
id: relatedTask.id,
|
|
subject: 'Related task',
|
|
related: [baseTask.id],
|
|
});
|
|
|
|
const blockedOwnerBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
memberName: 'alice',
|
|
},
|
|
55_1
|
|
);
|
|
const blockedOwnerBriefingText = (
|
|
((blockedOwnerBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(blockedOwnerBriefingText).toContain(`#${baseTask.displayId}`);
|
|
expect(blockedOwnerBriefingText).toContain('Actionable:');
|
|
expect(blockedOwnerBriefingText).toContain(`#${blockedTask.displayId}`);
|
|
expect(blockedOwnerBriefingText).toContain('Awareness:');
|
|
expect(blockedOwnerBriefingText).toContain('reason=dependency_waiting');
|
|
|
|
await client.callTool(
|
|
'task_complete',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
taskId: baseTask.id,
|
|
actor: 'alice',
|
|
},
|
|
55_2
|
|
);
|
|
|
|
const unblockedOwnerBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
memberName: 'alice',
|
|
},
|
|
55_3
|
|
);
|
|
const unblockedOwnerBriefingText = (
|
|
((unblockedOwnerBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(unblockedOwnerBriefingText).toContain(`#${blockedTask.displayId}`);
|
|
expect(unblockedOwnerBriefingText).toContain('Actionable:');
|
|
expect(unblockedOwnerBriefingText).toContain('reason=owner_ready');
|
|
expect(unblockedOwnerBriefingText).not.toContain('reason=dependency_waiting');
|
|
|
|
const selfReviewCreateResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
subject: 'Self review should be invalid',
|
|
owner: 'alice',
|
|
},
|
|
56
|
|
);
|
|
const selfReviewTask = parseJsonToolResult(
|
|
(selfReviewCreateResult as { result: unknown }).result
|
|
);
|
|
|
|
await client.callTool(
|
|
'task_complete',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
taskId: selfReviewTask.id,
|
|
actor: 'alice',
|
|
},
|
|
57
|
|
);
|
|
|
|
await client.callTool(
|
|
'review_request',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
taskId: selfReviewTask.id,
|
|
from: 'team-lead',
|
|
reviewer: 'alice',
|
|
},
|
|
58
|
|
);
|
|
|
|
const leadBriefingResult = await client.callTool(
|
|
'lead_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
},
|
|
59
|
|
);
|
|
const leadBriefingText = (
|
|
((leadBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(leadBriefingText).toContain('Needs reviewer assignment:');
|
|
expect(leadBriefingText).toContain(`#${selfReviewTask.displayId}`);
|
|
expect(leadBriefingText).toContain('reason=self_review_invalid');
|
|
|
|
const ownerBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'inventory-filters-team',
|
|
memberName: 'alice',
|
|
},
|
|
60
|
|
);
|
|
const ownerBriefingText = (
|
|
((ownerBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(ownerBriefingText).toContain(`#${selfReviewTask.displayId}`);
|
|
expect(ownerBriefingText).toContain('Awareness:');
|
|
expect(ownerBriefingText).toContain('reason=self_review_invalid');
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
});
|
|
|
|
it('routes clarification flags into owner awareness and lead sections over stdio', async () => {
|
|
await writeTeamConfig(claudeDir, 'clarification-team');
|
|
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
|
|
|
try {
|
|
await client.initialize();
|
|
|
|
const leadClarificationCreateResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'clarification-team',
|
|
subject: 'Need lead answer',
|
|
owner: 'alice',
|
|
},
|
|
71
|
|
);
|
|
const leadClarificationTask = parseJsonToolResult(
|
|
(leadClarificationCreateResult as { result: unknown }).result
|
|
);
|
|
|
|
const userClarificationCreateResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'clarification-team',
|
|
subject: 'Need user answer',
|
|
owner: 'alice',
|
|
},
|
|
72
|
|
);
|
|
const userClarificationTask = parseJsonToolResult(
|
|
(userClarificationCreateResult as { result: unknown }).result
|
|
);
|
|
|
|
await client.callTool(
|
|
'task_set_clarification',
|
|
{
|
|
claudeDir,
|
|
teamName: 'clarification-team',
|
|
taskId: leadClarificationTask.id,
|
|
value: 'lead',
|
|
},
|
|
73
|
|
);
|
|
|
|
await client.callTool(
|
|
'task_set_clarification',
|
|
{
|
|
claudeDir,
|
|
teamName: 'clarification-team',
|
|
taskId: userClarificationTask.id,
|
|
value: 'user',
|
|
},
|
|
74
|
|
);
|
|
|
|
const ownerBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'clarification-team',
|
|
memberName: 'alice',
|
|
},
|
|
75
|
|
);
|
|
const ownerBriefingText = (
|
|
((ownerBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(ownerBriefingText).toContain('Awareness:');
|
|
expect(ownerBriefingText).toContain(`#${leadClarificationTask.displayId}`);
|
|
expect(ownerBriefingText).toContain('reason=waiting_lead_clarification');
|
|
expect(ownerBriefingText).toContain('clarification=lead');
|
|
expect(ownerBriefingText).toContain(`#${userClarificationTask.displayId}`);
|
|
expect(ownerBriefingText).toContain('reason=waiting_user_clarification');
|
|
expect(ownerBriefingText).toContain('clarification=user');
|
|
|
|
const leadBriefingResult = await client.callTool(
|
|
'lead_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'clarification-team',
|
|
},
|
|
76
|
|
);
|
|
const leadBriefingText = (
|
|
((leadBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(leadBriefingText).toContain('Needs clarification from lead:');
|
|
expect(leadBriefingText).toContain(`#${leadClarificationTask.displayId}`);
|
|
expect(leadBriefingText).toContain('reason=waiting_lead_clarification');
|
|
expect(leadBriefingText).toContain('Waiting on user:');
|
|
expect(leadBriefingText).toContain(`#${userClarificationTask.displayId}`);
|
|
expect(leadBriefingText).toContain('reason=waiting_user_clarification');
|
|
|
|
const inventoryResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'clarification-team',
|
|
owner: 'alice',
|
|
status: 'pending',
|
|
},
|
|
77
|
|
);
|
|
const inventoryRows = parseJsonToolResult((inventoryResult as { result: unknown }).result);
|
|
const leadClarificationRow = inventoryRows.find(
|
|
(row: { id: string }) => row.id === leadClarificationTask.id
|
|
) as Record<string, unknown> | undefined;
|
|
const userClarificationRow = inventoryRows.find(
|
|
(row: { id: string }) => row.id === userClarificationTask.id
|
|
) as Record<string, unknown> | undefined;
|
|
expect(leadClarificationRow?.needsClarification).toBe('lead');
|
|
expect(userClarificationRow?.needsClarification).toBe('user');
|
|
|
|
await client.callTool(
|
|
'task_set_clarification',
|
|
{
|
|
claudeDir,
|
|
teamName: 'clarification-team',
|
|
taskId: leadClarificationTask.id,
|
|
value: 'clear',
|
|
},
|
|
78
|
|
);
|
|
|
|
const ownerAfterClearBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'clarification-team',
|
|
memberName: 'alice',
|
|
},
|
|
79
|
|
);
|
|
const ownerAfterClearBriefingText = (
|
|
((ownerAfterClearBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(ownerAfterClearBriefingText).toContain('Actionable:');
|
|
expect(ownerAfterClearBriefingText).toContain(`#${leadClarificationTask.displayId}`);
|
|
expect(ownerAfterClearBriefingText).toContain('reason=owner_ready');
|
|
expect(ownerAfterClearBriefingText).not.toContain('reason=waiting_lead_clarification');
|
|
expect(ownerAfterClearBriefingText).toContain(`#${userClarificationTask.displayId}`);
|
|
expect(ownerAfterClearBriefingText).toContain('reason=waiting_user_clarification');
|
|
|
|
const leadAfterClearBriefingResult = await client.callTool(
|
|
'lead_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'clarification-team',
|
|
},
|
|
80
|
|
);
|
|
const leadAfterClearBriefingText = (
|
|
((leadAfterClearBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(leadAfterClearBriefingText).not.toContain(`#${leadClarificationTask.displayId}`);
|
|
expect(leadAfterClearBriefingText).toContain('Waiting on user:');
|
|
expect(leadAfterClearBriefingText).toContain(`#${userClarificationTask.displayId}`);
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
});
|
|
|
|
it('routes lead-owned work and approved terminal awareness over stdio', async () => {
|
|
await writeTeamConfig(claudeDir, 'terminal-routing-team');
|
|
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
|
|
|
try {
|
|
await client.initialize();
|
|
|
|
const leadOwnedCreateResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'terminal-routing-team',
|
|
subject: 'Lead-owned follow-up task',
|
|
owner: 'team-lead',
|
|
},
|
|
81
|
|
);
|
|
const leadOwnedTask = parseJsonToolResult(
|
|
(leadOwnedCreateResult as { result: unknown }).result
|
|
);
|
|
|
|
const leadOwnedBriefingResult = await client.callTool(
|
|
'lead_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'terminal-routing-team',
|
|
},
|
|
82
|
|
);
|
|
const leadOwnedBriefingText = (
|
|
((leadOwnedBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(leadOwnedBriefingText).toContain('Lead-owned follow-up:');
|
|
expect(leadOwnedBriefingText).toContain(`#${leadOwnedTask.displayId}`);
|
|
expect(leadOwnedBriefingText).toContain('owner=team-lead');
|
|
expect(leadOwnedBriefingText).toContain('actionOwner=lead');
|
|
expect(leadOwnedBriefingText).toContain('reason=owner_ready');
|
|
|
|
const unrelatedMemberBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'terminal-routing-team',
|
|
memberName: 'alice',
|
|
},
|
|
83
|
|
);
|
|
const unrelatedMemberBriefingText = (
|
|
((unrelatedMemberBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(unrelatedMemberBriefingText).toContain('No actionable or awareness tasks for alice.');
|
|
expect(unrelatedMemberBriefingText).not.toContain(`#${leadOwnedTask.displayId}`);
|
|
|
|
const approvedCreateResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'terminal-routing-team',
|
|
subject: 'Approved terminal task',
|
|
owner: 'alice',
|
|
description: 'This should become terminal awareness, not work.',
|
|
},
|
|
84
|
|
);
|
|
const approvedTask = parseJsonToolResult((approvedCreateResult as { result: unknown }).result);
|
|
|
|
await client.callTool(
|
|
'task_complete',
|
|
{
|
|
claudeDir,
|
|
teamName: 'terminal-routing-team',
|
|
taskId: approvedTask.id,
|
|
actor: 'alice',
|
|
},
|
|
85
|
|
);
|
|
|
|
await client.callTool(
|
|
'review_request',
|
|
{
|
|
claudeDir,
|
|
teamName: 'terminal-routing-team',
|
|
taskId: approvedTask.id,
|
|
from: 'team-lead',
|
|
reviewer: 'bob',
|
|
},
|
|
86
|
|
);
|
|
|
|
await client.callTool(
|
|
'review_start',
|
|
{
|
|
claudeDir,
|
|
teamName: 'terminal-routing-team',
|
|
taskId: approvedTask.id,
|
|
from: 'bob',
|
|
},
|
|
87
|
|
);
|
|
|
|
const approveResult = await client.callTool(
|
|
'review_approve',
|
|
{
|
|
claudeDir,
|
|
teamName: 'terminal-routing-team',
|
|
taskId: approvedTask.id,
|
|
from: 'bob',
|
|
note: 'Approved through stdio e2e.',
|
|
notifyOwner: true,
|
|
},
|
|
88
|
|
);
|
|
const approvedPayload = parseJsonToolResult((approveResult as { result: unknown }).result);
|
|
expect(approvedPayload.reviewState).toBe('approved');
|
|
|
|
const ownerBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'terminal-routing-team',
|
|
memberName: 'alice',
|
|
},
|
|
89
|
|
);
|
|
const ownerBriefingText = (
|
|
((ownerBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(ownerBriefingText).not.toContain('Actionable:');
|
|
expect(ownerBriefingText).toContain('Awareness:');
|
|
expect(ownerBriefingText).toContain(`#${approvedTask.displayId}`);
|
|
expect(ownerBriefingText).toContain('review=approved');
|
|
expect(ownerBriefingText).toContain('actionOwner=none');
|
|
expect(ownerBriefingText).toContain('reason=terminal_approved');
|
|
expect(ownerBriefingText).not.toContain('Description: This should become terminal awareness');
|
|
|
|
const leadAfterApprovalBriefingResult = await client.callTool(
|
|
'lead_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'terminal-routing-team',
|
|
},
|
|
90
|
|
);
|
|
const leadAfterApprovalBriefingText = (
|
|
((leadAfterApprovalBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(leadAfterApprovalBriefingText).toContain(`#${leadOwnedTask.displayId}`);
|
|
expect(leadAfterApprovalBriefingText).not.toContain(`#${approvedTask.displayId}`);
|
|
|
|
const approvedInventoryResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'terminal-routing-team',
|
|
reviewState: 'approved',
|
|
kanbanColumn: 'approved',
|
|
},
|
|
91
|
|
);
|
|
const approvedInventoryRows = parseJsonToolResult(
|
|
(approvedInventoryResult as { result: unknown }).result
|
|
);
|
|
expect(approvedInventoryRows).toHaveLength(1);
|
|
expect(approvedInventoryRows[0]).toMatchObject({
|
|
id: approvedTask.id,
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
reviewState: 'approved',
|
|
});
|
|
expect(approvedInventoryRows[0].description).toBeUndefined();
|
|
expect(approvedInventoryRows[0].comments).toBeUndefined();
|
|
expect(approvedInventoryRows[0].historyEvents).toBeUndefined();
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
});
|
|
|
|
it('routes invalid owners and broken dependencies to lead over stdio', async () => {
|
|
await writeTeamConfig(claudeDir, 'repair-team');
|
|
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
|
|
|
try {
|
|
await client.initialize();
|
|
|
|
const orphanedTaskResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'repair-team',
|
|
subject: 'Owner became invalid',
|
|
owner: 'alice',
|
|
},
|
|
81
|
|
);
|
|
const orphanedTask = parseJsonToolResult((orphanedTaskResult as { result: unknown }).result);
|
|
|
|
const membersMetaPath = path.join(claudeDir, 'teams', 'repair-team', 'members.meta.json');
|
|
await writeFile(
|
|
membersMetaPath,
|
|
JSON.stringify(
|
|
{
|
|
version: 1,
|
|
members: [{ name: 'alice', removedAt: 1_776_772_800_000 }],
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
'utf8'
|
|
);
|
|
|
|
const dependencyTaskResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'repair-team',
|
|
subject: 'Dependency to be deleted',
|
|
owner: 'bob',
|
|
},
|
|
82
|
|
);
|
|
const dependencyTask = parseJsonToolResult(
|
|
(dependencyTaskResult as { result: unknown }).result
|
|
);
|
|
|
|
const blockedTaskResult = await client.callTool(
|
|
'task_create',
|
|
{
|
|
claudeDir,
|
|
teamName: 'repair-team',
|
|
subject: 'Broken dependency task',
|
|
owner: 'bob',
|
|
blockedBy: [dependencyTask.id],
|
|
},
|
|
83
|
|
);
|
|
const blockedTask = parseJsonToolResult((blockedTaskResult as { result: unknown }).result);
|
|
|
|
await client.callTool(
|
|
'task_set_status',
|
|
{
|
|
claudeDir,
|
|
teamName: 'repair-team',
|
|
taskId: dependencyTask.id,
|
|
status: 'deleted',
|
|
actor: 'bob',
|
|
},
|
|
84
|
|
);
|
|
|
|
const leadBriefingResult = await client.callTool(
|
|
'lead_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'repair-team',
|
|
},
|
|
85
|
|
);
|
|
const leadBriefingText = (
|
|
((leadBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(leadBriefingText).toContain('Needs owner assignment:');
|
|
expect(leadBriefingText).toContain(`#${orphanedTask.displayId}`);
|
|
expect(leadBriefingText).toContain('reason=owner_invalid');
|
|
expect(leadBriefingText).toContain('Dependency repair:');
|
|
expect(leadBriefingText).toContain(`#${blockedTask.displayId}`);
|
|
expect(leadBriefingText).toContain('reason=dependency_broken');
|
|
|
|
const bobBriefingResult = await client.callTool(
|
|
'task_briefing',
|
|
{
|
|
claudeDir,
|
|
teamName: 'repair-team',
|
|
memberName: 'bob',
|
|
},
|
|
86
|
|
);
|
|
const bobBriefingText = (
|
|
((bobBriefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
|
?.content?.[0]?.text as string | undefined) ?? ''
|
|
);
|
|
expect(bobBriefingText).toContain('Awareness:');
|
|
expect(bobBriefingText).toContain(`#${blockedTask.displayId}`);
|
|
expect(bobBriefingText).toContain('reason=dependency_broken');
|
|
expect(bobBriefingText).not.toContain('reason=owner_ready');
|
|
|
|
const inventoryResult = await client.callTool(
|
|
'task_list',
|
|
{
|
|
claudeDir,
|
|
teamName: 'repair-team',
|
|
owner: 'bob',
|
|
},
|
|
87
|
|
);
|
|
const inventoryRows = parseJsonToolResult((inventoryResult as { result: unknown }).result);
|
|
const blockedRow = inventoryRows.find(
|
|
(row: { id: string }) => row.id === blockedTask.id
|
|
) as Record<string, unknown> | undefined;
|
|
expect(blockedRow).toMatchObject({
|
|
id: blockedTask.id,
|
|
owner: 'bob',
|
|
blockedBy: [dependencyTask.id],
|
|
status: 'pending',
|
|
});
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
});
|
|
});
|