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",
|
"dev": "tsx src/index.ts",
|
||||||
"lint": "eslint \"src/**/*.ts\"",
|
"lint": "eslint \"src/**/*.ts\"",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:e2e": "pnpm build && vitest run test/stdio.e2e.test.ts",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
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) {
|
export function getController(teamName: string, claudeDir?: string) {
|
||||||
return createController({
|
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,
|
clean: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
dts: false,
|
dts: false,
|
||||||
banner: {
|
|
||||||
js: '#!/usr/bin/env node',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
"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",
|
"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",
|
"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",
|
"check": "pnpm check:workspace && pnpm lint && pnpm lint:mcp",
|
||||||
"fix": "pnpm lint:fix && pnpm format",
|
"fix": "pnpm lint:fix && pnpm format",
|
||||||
"quality": "pnpm check && pnpm format:check && npx knip",
|
"quality": "pnpm check && pnpm format:check && npx knip",
|
||||||
|
|
|
||||||
|
|
@ -475,7 +475,10 @@ async function handleDeleteTeam(
|
||||||
if (!validated.valid) {
|
if (!validated.valid) {
|
||||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
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(
|
async function handleRestoreTeam(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { execFile } from 'child_process';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
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> {
|
async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
||||||
const builtEntry = getBuiltServerEntry();
|
const builtEntry = getBuiltServerEntry();
|
||||||
if (await pathExists(builtEntry)) {
|
if (await pathExists(builtEntry)) {
|
||||||
return {
|
return {
|
||||||
command: process.execPath,
|
command: await resolveNodePath(),
|
||||||
args: [builtEntry],
|
args: [builtEntry],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ export const TaskDetailDialog = ({
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
||||||
#{currentTask.id}
|
{formatTaskDisplayLabel(currentTask)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
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 { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||||
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
|
|
@ -63,7 +64,11 @@ export const MemberCard = ({
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
background: `linear-gradient(to right, ${getThemedBadge(colors, isLight)}, transparent)`,
|
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"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={onClick}
|
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 ? '…' : ''}
|
{currentTask.subject.length > 36 ? '…' : ''}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|
@ -160,7 +165,7 @@ export const MemberCard = ({
|
||||||
isRemoved
|
isRemoved
|
||||||
? 'This member has been removed'
|
? 'This member has been removed'
|
||||||
: member.currentTaskId
|
: member.currentTaskId
|
||||||
? `Current task: ${member.currentTaskId}`
|
? `Current task: #${deriveTaskDisplayId(member.currentTaskId)}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue