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:
iliya 2026-03-07 20:48:03 +02:00
parent f7130c3437
commit c2d0a20811
9 changed files with 211 additions and 11 deletions

View file

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

View file

@ -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({

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

View file

@ -8,7 +8,4 @@ export default defineConfig({
clean: true,
sourcemap: true,
dts: false,
banner: {
js: '#!/usr/bin/env node',
},
});

View file

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

View file

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

View file

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

View file

@ -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}`}

View file

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