chore: enhance package scripts and improve MCP server functionality
- Updated the check:workspace script to include end-to-end testing for the MCP package. - Added a new test:e2e script in the MCP server for running end-to-end tests. - Refactored controller import logic to support default exports from the agent-teams-controller. - Improved team deletion handling in IPC by ensuring team provisioning is stopped before deletion. - Introduced a function to resolve the real node binary path for better compatibility in Electron environments. - Enhanced task display in MemberCard and TaskDetailDialog components for improved user experience.
This commit is contained in:
parent
f7130c3437
commit
c2d0a20811
9 changed files with 211 additions and 11 deletions
|
|
@ -29,6 +29,7 @@
|
|||
"dev": "tsx src/index.ts",
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "pnpm build && vitest run test/stdio.e2e.test.ts",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||
|
||||
const { createController } = agentTeamsControllerModule;
|
||||
type ControllerModule = typeof import('agent-teams-controller') & {
|
||||
default?: typeof import('agent-teams-controller');
|
||||
};
|
||||
|
||||
const controllerModule =
|
||||
(agentTeamsControllerModule as ControllerModule).default ?? agentTeamsControllerModule;
|
||||
const { createController } = controllerModule;
|
||||
|
||||
export function getController(teamName: string, claudeDir?: string) {
|
||||
return createController({
|
||||
|
|
|
|||
154
mcp-server/test/stdio.e2e.test.ts
Normal file
154
mcp-server/test/stdio.e2e.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { mkdtemp, rm } 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 text = (result as { content?: Array<{ text?: string }> }).content?.[0]?.text;
|
||||
return JSON.parse(text ?? 'null');
|
||||
}
|
||||
|
||||
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() + 5000;
|
||||
|
||||
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 () => {
|
||||
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 }> };
|
||||
};
|
||||
const toolNames = (tools.result?.tools ?? []).map((tool) => tool.name);
|
||||
|
||||
expect(toolNames).toContain('task_create');
|
||||
expect(toolNames).toContain('task_start');
|
||||
expect(toolNames).toContain('review_approve');
|
||||
|
||||
const createResult = await client.callTool(
|
||||
'task_create',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'e2e-team',
|
||||
subject: 'Smoke task',
|
||||
owner: 'alice',
|
||||
},
|
||||
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);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,4 @@ export default defineConfig({
|
|||
clean: true,
|
||||
sourcemap: true,
|
||||
dts: false,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||
"build:workspace": "pnpm build && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||
"test:workspace": "pnpm test && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test",
|
||||
"check:workspace": "pnpm typecheck:workspace && pnpm test:workspace && pnpm build:workspace",
|
||||
"check:workspace": "pnpm typecheck:workspace && pnpm test:workspace && pnpm build:workspace && pnpm --filter agent-teams-mcp test:e2e",
|
||||
"check": "pnpm check:workspace && pnpm lint && pnpm lint:mcp",
|
||||
"fix": "pnpm lint:fix && pnpm format",
|
||||
"quality": "pnpm check && pnpm format:check && npx knip",
|
||||
|
|
|
|||
|
|
@ -475,7 +475,10 @@ async function handleDeleteTeam(
|
|||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
return wrapTeamHandler('deleteTeam', () => getTeamDataService().deleteTeam(validated.value!));
|
||||
return wrapTeamHandler('deleteTeam', async () => {
|
||||
getTeamProvisioningService().stopTeam(validated.value!);
|
||||
await getTeamDataService().deleteTeam(validated.value!);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRestoreTeam(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { execFile } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
|
|
@ -37,11 +38,44 @@ async function pathExists(targetPath: string): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
let _resolvedNodePath: string | undefined;
|
||||
|
||||
/**
|
||||
* Find the real `node` binary path. In Electron, process.execPath is the
|
||||
* Electron binary — NOT node — so we must resolve node separately.
|
||||
* Uses async execFile('node', ...) which is cross-platform (no /usr/bin/env dependency).
|
||||
*/
|
||||
async function resolveNodePath(): Promise<string> {
|
||||
if (_resolvedNodePath) return _resolvedNodePath;
|
||||
|
||||
try {
|
||||
const resolved = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
'node',
|
||||
['-e', 'process.stdout.write(process.execPath)'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
},
|
||||
(err, stdout) => (err ? reject(err) : resolve(stdout.trim()))
|
||||
);
|
||||
});
|
||||
if (resolved) {
|
||||
_resolvedNodePath = resolved;
|
||||
return _resolvedNodePath;
|
||||
}
|
||||
} catch {
|
||||
// node not found or timed out — use bare 'node' and let the OS resolve it
|
||||
}
|
||||
_resolvedNodePath = 'node';
|
||||
return _resolvedNodePath;
|
||||
}
|
||||
|
||||
async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
||||
const builtEntry = getBuiltServerEntry();
|
||||
if (await pathExists(builtEntry)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
command: await resolveNodePath(),
|
||||
args: [builtEntry],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -332,7 +332,7 @@ export const TaskDetailDialog = ({
|
|||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
||||
#{currentTask.id}
|
||||
{formatTaskDisplayLabel(currentTask)}
|
||||
</Badge>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||
|
||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
|
|
@ -63,7 +64,11 @@ export const MemberCard = ({
|
|||
borderLeft: `3px solid ${colors.border}`,
|
||||
background: `linear-gradient(to right, ${getThemedBadge(colors, isLight)}, transparent)`,
|
||||
}}
|
||||
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
|
||||
title={
|
||||
member.currentTaskId
|
||||
? `Current task: #${deriveTaskDisplayId(member.currentTaskId)}`
|
||||
: undefined
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
|
|
@ -122,7 +127,7 @@ export const MemberCard = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
#{currentTask.id} {currentTask.subject.slice(0, 36)}
|
||||
{formatTaskDisplayLabel(currentTask)} {currentTask.subject.slice(0, 36)}
|
||||
{currentTask.subject.length > 36 ? '…' : ''}
|
||||
</button>
|
||||
</>
|
||||
|
|
@ -160,7 +165,7 @@ export const MemberCard = ({
|
|||
isRemoved
|
||||
? 'This member has been removed'
|
||||
: member.currentTaskId
|
||||
? `Current task: ${member.currentTaskId}`
|
||||
? `Current task: #${deriveTaskDisplayId(member.currentTaskId)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in a new issue