feat(agent-teams): integrate MCP tool catalog and enhance tool registration
- Added mcpToolCatalog to the agent-teams-controller, exporting new types and constants for MCP tool groups and names. - Updated tools registration to utilize AGENT_TEAMS_MCP_TOOL_GROUPS for streamlined tool management. - Enhanced tests to validate the new operational permissions and ensure correct tool registration behavior.
This commit is contained in:
parent
9241970b02
commit
822bbac23c
12 changed files with 442 additions and 169 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
const controller = require('./controller.js');
|
const controller = require('./controller.js');
|
||||||
|
const mcpToolCatalog = require('./mcpToolCatalog.js');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...controller,
|
...controller,
|
||||||
|
...mcpToolCatalog,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
115
agent-teams-controller/src/mcpToolCatalog.js
Normal file
115
agent-teams-controller/src/mcpToolCatalog.js
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
const AGENT_TEAMS_TASK_TOOL_NAMES = [
|
||||||
|
'member_briefing',
|
||||||
|
'task_add_comment',
|
||||||
|
'task_attach_comment_file',
|
||||||
|
'task_attach_file',
|
||||||
|
'task_briefing',
|
||||||
|
'task_complete',
|
||||||
|
'task_create',
|
||||||
|
'task_create_from_message',
|
||||||
|
'task_get',
|
||||||
|
'task_get_comment',
|
||||||
|
'task_link',
|
||||||
|
'task_list',
|
||||||
|
'task_set_clarification',
|
||||||
|
'task_set_owner',
|
||||||
|
'task_set_status',
|
||||||
|
'task_start',
|
||||||
|
'task_unlink',
|
||||||
|
];
|
||||||
|
|
||||||
|
const AGENT_TEAMS_REVIEW_TOOL_NAMES = [
|
||||||
|
'review_approve',
|
||||||
|
'review_request',
|
||||||
|
'review_request_changes',
|
||||||
|
'review_start',
|
||||||
|
];
|
||||||
|
|
||||||
|
const AGENT_TEAMS_MESSAGE_TOOL_NAMES = ['message_send'];
|
||||||
|
|
||||||
|
const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES = [
|
||||||
|
'cross_team_get_outbox',
|
||||||
|
'cross_team_list_targets',
|
||||||
|
'cross_team_send',
|
||||||
|
];
|
||||||
|
|
||||||
|
const AGENT_TEAMS_PROCESS_TOOL_NAMES = [
|
||||||
|
'process_list',
|
||||||
|
'process_register',
|
||||||
|
'process_stop',
|
||||||
|
'process_unregister',
|
||||||
|
];
|
||||||
|
|
||||||
|
const AGENT_TEAMS_KANBAN_TOOL_NAMES = [
|
||||||
|
'kanban_add_reviewer',
|
||||||
|
'kanban_clear',
|
||||||
|
'kanban_get',
|
||||||
|
'kanban_list_reviewers',
|
||||||
|
'kanban_remove_reviewer',
|
||||||
|
'kanban_set_column',
|
||||||
|
];
|
||||||
|
|
||||||
|
const AGENT_TEAMS_RUNTIME_TOOL_NAMES = ['team_launch', 'team_stop'];
|
||||||
|
|
||||||
|
const AGENT_TEAMS_MCP_TOOL_GROUPS = [
|
||||||
|
{
|
||||||
|
id: 'task',
|
||||||
|
teammateOperational: true,
|
||||||
|
toolNames: AGENT_TEAMS_TASK_TOOL_NAMES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kanban',
|
||||||
|
teammateOperational: false,
|
||||||
|
toolNames: AGENT_TEAMS_KANBAN_TOOL_NAMES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'review',
|
||||||
|
teammateOperational: true,
|
||||||
|
toolNames: AGENT_TEAMS_REVIEW_TOOL_NAMES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'message',
|
||||||
|
teammateOperational: true,
|
||||||
|
toolNames: AGENT_TEAMS_MESSAGE_TOOL_NAMES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'process',
|
||||||
|
teammateOperational: true,
|
||||||
|
toolNames: AGENT_TEAMS_PROCESS_TOOL_NAMES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runtime',
|
||||||
|
teammateOperational: false,
|
||||||
|
toolNames: AGENT_TEAMS_RUNTIME_TOOL_NAMES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crossTeam',
|
||||||
|
teammateOperational: true,
|
||||||
|
toolNames: AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const AGENT_TEAMS_REGISTERED_TOOL_NAMES = AGENT_TEAMS_MCP_TOOL_GROUPS.flatMap((group) => [
|
||||||
|
...group.toolNames,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES = AGENT_TEAMS_MCP_TOOL_GROUPS.filter(
|
||||||
|
(group) => group.teammateOperational
|
||||||
|
).flatMap((group) => [...group.toolNames]);
|
||||||
|
|
||||||
|
const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES =
|
||||||
|
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
AGENT_TEAMS_TASK_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_REVIEW_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_MESSAGE_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_PROCESS_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_KANBAN_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_RUNTIME_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_MCP_TOOL_GROUPS,
|
||||||
|
AGENT_TEAMS_REGISTERED_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
||||||
|
};
|
||||||
27
mcp-server/src/agent-teams-controller.d.ts
vendored
27
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -108,4 +108,31 @@ declare module 'agent-teams-controller' {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const protocols: ProtocolsApi;
|
export const protocols: ProtocolsApi;
|
||||||
|
|
||||||
|
export type AgentTeamsMcpToolGroupId =
|
||||||
|
| 'task'
|
||||||
|
| 'kanban'
|
||||||
|
| 'review'
|
||||||
|
| 'message'
|
||||||
|
| 'process'
|
||||||
|
| 'runtime'
|
||||||
|
| 'crossTeam';
|
||||||
|
|
||||||
|
export interface AgentTeamsMcpToolGroup {
|
||||||
|
id: AgentTeamsMcpToolGroupId;
|
||||||
|
teammateOperational: boolean;
|
||||||
|
toolNames: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[];
|
||||||
|
export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import type { FastMCP } from 'fastmcp';
|
import type { FastMCP } from 'fastmcp';
|
||||||
|
|
||||||
|
import { AGENT_TEAMS_MCP_TOOL_GROUPS, AGENT_TEAMS_REGISTERED_TOOL_NAMES } from 'agent-teams-controller';
|
||||||
|
|
||||||
import { registerCrossTeamTools } from './crossTeamTools';
|
import { registerCrossTeamTools } from './crossTeamTools';
|
||||||
import { registerKanbanTools } from './kanbanTools';
|
import { registerKanbanTools } from './kanbanTools';
|
||||||
import { registerMessageTools } from './messageTools';
|
import { registerMessageTools } from './messageTools';
|
||||||
|
|
@ -8,12 +10,25 @@ import { registerReviewTools } from './reviewTools';
|
||||||
import { registerRuntimeTools } from './runtimeTools';
|
import { registerRuntimeTools } from './runtimeTools';
|
||||||
import { registerTaskTools } from './taskTools';
|
import { registerTaskTools } from './taskTools';
|
||||||
|
|
||||||
|
const REGISTRATION_BY_GROUP = {
|
||||||
|
task: registerTaskTools,
|
||||||
|
kanban: registerKanbanTools,
|
||||||
|
review: registerReviewTools,
|
||||||
|
message: registerMessageTools,
|
||||||
|
process: registerProcessTools,
|
||||||
|
runtime: registerRuntimeTools,
|
||||||
|
crossTeam: registerCrossTeamTools,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const AGENT_TEAMS_MCP_REGISTRATION_GROUPS = AGENT_TEAMS_MCP_TOOL_GROUPS.map((group) => ({
|
||||||
|
...group,
|
||||||
|
register: REGISTRATION_BY_GROUP[group.id as keyof typeof REGISTRATION_BY_GROUP],
|
||||||
|
}));
|
||||||
|
|
||||||
|
export { AGENT_TEAMS_REGISTERED_TOOL_NAMES };
|
||||||
|
|
||||||
export function registerTools(server: FastMCP) {
|
export function registerTools(server: FastMCP) {
|
||||||
registerTaskTools(server);
|
for (const group of AGENT_TEAMS_MCP_REGISTRATION_GROUPS) {
|
||||||
registerKanbanTools(server);
|
group.register(server);
|
||||||
registerReviewTools(server);
|
}
|
||||||
registerMessageTools(server);
|
|
||||||
registerProcessTools(server);
|
|
||||||
registerRuntimeTools(server);
|
|
||||||
registerCrossTeamTools(server);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import http from 'http';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { registerTools } from '../src/tools';
|
import { AGENT_TEAMS_REGISTERED_TOOL_NAMES, registerTools } from '../src/tools';
|
||||||
|
|
||||||
type RegisteredTool = {
|
type RegisteredTool = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -30,45 +30,6 @@ function parseJsonToolResult(result: unknown) {
|
||||||
|
|
||||||
describe('agent-teams-mcp tools', () => {
|
describe('agent-teams-mcp tools', () => {
|
||||||
const tools = collectTools();
|
const tools = collectTools();
|
||||||
const expectedToolNames = [
|
|
||||||
'cross_team_get_outbox',
|
|
||||||
'cross_team_list_targets',
|
|
||||||
'cross_team_send',
|
|
||||||
'kanban_add_reviewer',
|
|
||||||
'kanban_clear',
|
|
||||||
'kanban_get',
|
|
||||||
'kanban_list_reviewers',
|
|
||||||
'kanban_remove_reviewer',
|
|
||||||
'kanban_set_column',
|
|
||||||
'member_briefing',
|
|
||||||
'message_send',
|
|
||||||
'process_list',
|
|
||||||
'process_register',
|
|
||||||
'process_stop',
|
|
||||||
'process_unregister',
|
|
||||||
'review_approve',
|
|
||||||
'review_request',
|
|
||||||
'review_request_changes',
|
|
||||||
'review_start',
|
|
||||||
'task_add_comment',
|
|
||||||
'task_attach_comment_file',
|
|
||||||
'task_attach_file',
|
|
||||||
'task_briefing',
|
|
||||||
'task_complete',
|
|
||||||
'task_create',
|
|
||||||
'task_create_from_message',
|
|
||||||
'task_get',
|
|
||||||
'task_get_comment',
|
|
||||||
'task_link',
|
|
||||||
'task_list',
|
|
||||||
'task_set_clarification',
|
|
||||||
'task_set_owner',
|
|
||||||
'task_set_status',
|
|
||||||
'task_start',
|
|
||||||
'task_unlink',
|
|
||||||
'team_launch',
|
|
||||||
'team_stop',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function getTool(name: string) {
|
function getTool(name: string) {
|
||||||
const tool = tools.get(name);
|
const tool = tools.get(name);
|
||||||
|
|
@ -147,7 +108,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
it('registers the full expected MCP tool surface', () => {
|
it('registers the full expected MCP tool surface', () => {
|
||||||
expect([...tools.keys()].sort()).toEqual([...expectedToolNames]);
|
expect([...tools.keys()].sort()).toEqual([...AGENT_TEAMS_REGISTERED_TOOL_NAMES].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts explicit conversation threading fields for cross_team_send', () => {
|
it('accepts explicit conversation threading fields for cross_team_send', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getHomeDir, getMcpConfigsBasePath, getMcpServerBasePath } from '@main/utils/pathDecoder';
|
import { getMcpConfigsBasePath, getMcpServerBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
@ -14,8 +14,6 @@ interface McpLaunchSpec {
|
||||||
|
|
||||||
const MCP_SERVER_NAME = 'agent-teams';
|
const MCP_SERVER_NAME = 'agent-teams';
|
||||||
const logger = createLogger('Service:TeamMcpConfigBuilder');
|
const logger = createLogger('Service:TeamMcpConfigBuilder');
|
||||||
const USER_MCP_CONFIG_NAME = '.claude.json';
|
|
||||||
|
|
||||||
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
|
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
|
||||||
/**
|
/**
|
||||||
* Stale configs older than this are removed on startup (best-effort).
|
* Stale configs older than this are removed on startup (best-effort).
|
||||||
|
|
@ -27,10 +25,6 @@ const MCP_CONFIG_STALE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
type McpServerConfig = Record<string, unknown>;
|
type McpServerConfig = Record<string, unknown>;
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPackagedApp(): boolean {
|
function isPackagedApp(): boolean {
|
||||||
try {
|
try {
|
||||||
const { app } = require('electron') as typeof import('electron');
|
const { app } = require('electron') as typeof import('electron');
|
||||||
|
|
@ -250,21 +244,23 @@ export class TeamMcpConfigBuilder {
|
||||||
configDir,
|
configDir,
|
||||||
`${MCP_CONFIG_PREFIX}${process.pid}-${Date.now()}-${randomUUID()}.json`
|
`${MCP_CONFIG_PREFIX}${process.pid}-${Date.now()}-${randomUUID()}.json`
|
||||||
);
|
);
|
||||||
const userServers = await this.readUserMcpServers();
|
// Keep the team bootstrap config minimal: recent Claude sidechain runs can
|
||||||
|
// lose the agent-teams tool surface when we inline large user MCP bundles
|
||||||
|
// into the generated --mcp-config. User/project/local MCP remain loaded
|
||||||
|
// through Claude's native settings sources.
|
||||||
const generatedServers: Record<string, McpServerConfig> = {
|
const generatedServers: Record<string, McpServerConfig> = {
|
||||||
[MCP_SERVER_NAME]: {
|
[MCP_SERVER_NAME]: {
|
||||||
command: launchSpec.command,
|
command: launchSpec.command,
|
||||||
args: launchSpec.args,
|
args: launchSpec.args,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const mergedServers = this.mergeServers(userServers, generatedServers);
|
|
||||||
|
|
||||||
await fs.promises.mkdir(configDir, { recursive: true });
|
await fs.promises.mkdir(configDir, { recursive: true });
|
||||||
await atomicWriteAsync(
|
await atomicWriteAsync(
|
||||||
configPath,
|
configPath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
mcpServers: mergedServers,
|
mcpServers: generatedServers,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
|
|
@ -336,61 +332,4 @@ export class TeamMcpConfigBuilder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readUserMcpServers(): Promise<Record<string, McpServerConfig>> {
|
|
||||||
const configPath = path.join(getHomeDir(), USER_MCP_CONFIG_NAME);
|
|
||||||
return this.readMcpServersFromFile(configPath, 'user');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readMcpServersFromFile(
|
|
||||||
filePath: string,
|
|
||||||
scope: 'user'
|
|
||||||
): Promise<Record<string, McpServerConfig>> {
|
|
||||||
try {
|
|
||||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
|
||||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
||||||
const mcpServers = parsed.mcpServers;
|
|
||||||
if (!isRecord(mcpServers)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(mcpServers).filter(([, config]) => isRecord(config))
|
|
||||||
) as Record<string, McpServerConfig>;
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as NodeJS.ErrnoException;
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn(
|
|
||||||
`Failed to read ${scope} MCP config from ${filePath}: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mergeServers(
|
|
||||||
userServers: Record<string, McpServerConfig>,
|
|
||||||
generatedServers: Record<string, McpServerConfig>
|
|
||||||
): Record<string, McpServerConfig> {
|
|
||||||
const duplicates = Object.keys(userServers).filter((name) =>
|
|
||||||
Object.hasOwn(generatedServers, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (duplicates.length > 0) {
|
|
||||||
logger.info(`Merging MCP configs with overrides for: ${duplicates.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// We inline only top-level user MCP into --mcp-config.
|
|
||||||
// Project/local scopes are still loaded natively by Claude via
|
|
||||||
// --setting-sources user,project,local, which preserves documented precedence:
|
|
||||||
// local > project > user. Generated agent-teams must always win on name collision.
|
|
||||||
return {
|
|
||||||
...userServers,
|
|
||||||
...generatedServers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||||
import {
|
import {
|
||||||
isInboxNoiseMessage,
|
isInboxNoiseMessage,
|
||||||
parsePermissionRequest,
|
|
||||||
type ParsedPermissionRequest,
|
type ParsedPermissionRequest,
|
||||||
|
parsePermissionRequest,
|
||||||
} from '@shared/utils/inboxNoise';
|
} from '@shared/utils/inboxNoise';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
@ -109,7 +109,8 @@ import type {
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamProvisioning');
|
const logger = createLogger('Service:TeamProvisioning');
|
||||||
const { createController, protocols } = agentTeamsControllerModule;
|
const { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, createController, protocols } =
|
||||||
|
agentTeamsControllerModule;
|
||||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||||
const RUN_TIMEOUT_MS = 300_000;
|
const RUN_TIMEOUT_MS = 300_000;
|
||||||
const VERIFY_TIMEOUT_MS = 15_000;
|
const VERIFY_TIMEOUT_MS = 15_000;
|
||||||
|
|
@ -2641,7 +2642,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const mins = Math.floor(silenceSec / 60);
|
const mins = Math.floor(silenceSec / 60);
|
||||||
const secs = silenceSec % 60;
|
const secs = silenceSec % 60;
|
||||||
const elapsed = mins > 0 ? `${mins}m ${secs > 0 ? `${secs}s` : ''}` : `${secs}s`;
|
const elapsed = mins > 0 ? (secs > 0 ? `${mins}m ${secs}s` : `${mins}m`) : `${secs}s`;
|
||||||
|
|
||||||
// If retry messages are flowing, they are more informative than our
|
// If retry messages are flowing, they are more informative than our
|
||||||
// generic stall text — don't overwrite progress.message / severity.
|
// generic stall text — don't overwrite progress.message / severity.
|
||||||
|
|
@ -2652,7 +2653,7 @@ export class TeamProvisioningService {
|
||||||
...run.progress,
|
...run.progress,
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
...(!retryActive && {
|
...(!retryActive && {
|
||||||
message: `CLI not responding for ${elapsed} — possible rate limit`,
|
message: this.buildStallProgressMessage(silenceSec, elapsed),
|
||||||
messageSeverity: 'warning' as const,
|
messageSeverity: 'warning' as const,
|
||||||
}),
|
}),
|
||||||
assistantOutput: run.provisioningOutputParts.join('\n\n'),
|
assistantOutput: run.provisioningOutputParts.join('\n\n'),
|
||||||
|
|
@ -2678,15 +2679,15 @@ export class TeamProvisioningService {
|
||||||
private buildStallWarningText(silenceSec: number, run: ProvisioningRun): string {
|
private buildStallWarningText(silenceSec: number, run: ProvisioningRun): string {
|
||||||
const mins = Math.floor(silenceSec / 60);
|
const mins = Math.floor(silenceSec / 60);
|
||||||
const secs = silenceSec % 60;
|
const secs = silenceSec % 60;
|
||||||
const elapsed = mins > 0 ? `${mins}m ${secs > 0 ? `${secs}s` : ''}` : `${secs}s`;
|
const elapsed = mins > 0 ? (secs > 0 ? `${mins}m ${secs}s` : `${mins}m`) : `${secs}s`;
|
||||||
|
|
||||||
if (silenceSec < 60) {
|
if (silenceSec < 60) {
|
||||||
return (
|
return (
|
||||||
`---\n\n` +
|
`---\n\n` +
|
||||||
`**Waiting for CLI response** (silent for ${elapsed})\n\n` +
|
`**Waiting for CLI response** (silent for ${elapsed})\n\n` +
|
||||||
`The process is running but not producing output yet. ` +
|
`The process is running but not producing output yet. Cloud sometimes delays logs, ` +
|
||||||
`This may be caused by an API delay (rate limit / model cooldown) — ` +
|
`and short waits like this are normal. The SDK also retries automatically if the ` +
|
||||||
`the SDK retries automatically.\n\n` +
|
`request briefly hits rate limiting.\n\n` +
|
||||||
`Waiting...`
|
`Waiting...`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2695,9 +2696,10 @@ export class TeamProvisioningService {
|
||||||
return (
|
return (
|
||||||
`---\n\n` +
|
`---\n\n` +
|
||||||
`**Waiting for CLI response** (silent for ${elapsed})\n\n` +
|
`**Waiting for CLI response** (silent for ${elapsed})\n\n` +
|
||||||
`The process is still not responding. Likely delayed due to rate limiting ` +
|
`The process is still waiting on Cloud. Logs can sometimes show up after ` +
|
||||||
`(error 429 / model cooldown). The SDK retries the request automatically — ` +
|
`1-1.5 minutes, and that is still okay. The SDK retries automatically if the ` +
|
||||||
`this usually resolves within 1-3 minutes.\n\n` +
|
`request hits rate limiting (error 429 / model cooldown).\n\n` +
|
||||||
|
`If there is still no output after 2 minutes, that starts to look unusual.\n\n` +
|
||||||
`You can cancel and try again later if the wait continues.`
|
`You can cancel and try again later if the wait continues.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2708,15 +2710,23 @@ export class TeamProvisioningService {
|
||||||
return (
|
return (
|
||||||
`---\n\n` +
|
`---\n\n` +
|
||||||
`**Extended CLI wait** (silent for ${elapsed})\n\n` +
|
`**Extended CLI wait** (silent for ${elapsed})\n\n` +
|
||||||
`Model **${modelName}**${effortLabel} appears to be under heavy load and is not responding. ` +
|
`Model **${modelName}**${effortLabel} is still waiting on Cloud. Some delay is normal, ` +
|
||||||
`Most likely this is a 429 error (rate limit / model cooldown).\n\n` +
|
`but no logs for ${elapsed} is already unusual.\n\n` +
|
||||||
`The process has been silent for over ${mins} minutes. Possible causes:\n` +
|
`Possible causes:\n` +
|
||||||
`- Rate limiting / model cooldown (429) — SDK retries automatically\n` +
|
`- Rate limiting / model cooldown (429) — SDK retries automatically\n` +
|
||||||
`- API server overload for this model\n\n` +
|
`- API server overload for this model\n` +
|
||||||
|
`- A stalled or delayed Cloud response\n\n` +
|
||||||
`Consider canceling and trying with a different model.`
|
`Consider canceling and trying with a different model.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildStallProgressMessage(silenceSec: number, elapsed: string): string {
|
||||||
|
if (silenceSec < 120) {
|
||||||
|
return `Waiting on Cloud response for ${elapsed} — logs can be delayed, this is still OK`;
|
||||||
|
}
|
||||||
|
return `Still waiting on Cloud response for ${elapsed} — this is unusual`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects auth failure keywords in stderr/stdout during provisioning.
|
* Detects auth failure keywords in stderr/stdout during provisioning.
|
||||||
* On first detection: kills process, waits, and respawns automatically.
|
* On first detection: kills process, waits, and respawns automatically.
|
||||||
|
|
@ -3216,6 +3226,9 @@ export class TeamProvisioningService {
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
if (request.skipPermissions === false) {
|
||||||
|
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
|
||||||
|
}
|
||||||
|
|
||||||
child = spawnCli(claudePath, spawnArgs, {
|
child = spawnCli(claudePath, spawnArgs, {
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
|
|
@ -3663,6 +3676,9 @@ export class TeamProvisioningService {
|
||||||
// Without it, CLI creates a fresh session ID automatically.
|
// Without it, CLI creates a fresh session ID automatically.
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (request.skipPermissions === false) {
|
||||||
|
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
|
||||||
|
}
|
||||||
child = spawnCli(claudePath, launchArgs, {
|
child = spawnCli(claudePath, launchArgs, {
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
env: { ...shellEnv },
|
env: { ...shellEnv },
|
||||||
|
|
@ -6375,23 +6391,18 @@ export class TeamProvisioningService {
|
||||||
.filter((name): name is string => typeof name === 'string' && name.length > 0);
|
.filter((name): name is string => typeof name === 'string' && name.length > 0);
|
||||||
if (toolNames.length === 0) continue;
|
if (toolNames.length === 0) continue;
|
||||||
|
|
||||||
// When approving ANY mcp__agent-teams__ tool, proactively add ALL agent-teams tools.
|
// Expand teammate-safe operational tools only.
|
||||||
// FACT: Teammates need multiple MCP tools (member_briefing, task_get, task_start, etc.)
|
// This removes the bootstrap/task workflow race without accidentally granting
|
||||||
// FACT: Each tool generates a separate permission_request, but by the time we process it
|
// admin/runtime tools like team_stop or kanban_clear.
|
||||||
// the teammate is already stuck waiting. Pre-adding all tools prevents future blocks.
|
if (
|
||||||
if (toolNames.some((name) => name.startsWith('mcp__agent-teams__'))) {
|
toolNames.some((name) =>
|
||||||
const agentTeamsTools = [
|
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES.includes(name)
|
||||||
'mcp__agent-teams__member_briefing',
|
)
|
||||||
'mcp__agent-teams__task_briefing',
|
) {
|
||||||
'mcp__agent-teams__task_create',
|
const merged = new Set([
|
||||||
'mcp__agent-teams__task_get',
|
...toolNames,
|
||||||
'mcp__agent-teams__task_list',
|
...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
||||||
'mcp__agent-teams__task_start',
|
]);
|
||||||
'mcp__agent-teams__task_complete',
|
|
||||||
'mcp__agent-teams__task_set_status',
|
|
||||||
'mcp__agent-teams__task_add_comment',
|
|
||||||
];
|
|
||||||
const merged = new Set([...toolNames, ...agentTeamsTools]);
|
|
||||||
toolNames = Array.from(merged);
|
toolNames = Array.from(merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6426,7 +6437,7 @@ export class TeamProvisioningService {
|
||||||
settingsPath: string,
|
settingsPath: string,
|
||||||
toolNames: string[],
|
toolNames: string[],
|
||||||
behavior: string
|
behavior: string
|
||||||
): Promise<void> {
|
): Promise<number> {
|
||||||
const dir = path.dirname(settingsPath);
|
const dir = path.dirname(settingsPath);
|
||||||
await fs.promises.mkdir(dir, { recursive: true });
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
|
@ -6465,9 +6476,33 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (added === 0) return; // Nothing new to add
|
if (added === 0) return 0; // Nothing new to add
|
||||||
|
|
||||||
await fs.promises.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
await atomicWriteAsync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedTeammateOperationalPermissionRules(
|
||||||
|
teamName: string,
|
||||||
|
projectCwd: string
|
||||||
|
): Promise<void> {
|
||||||
|
const settingsPath = path.join(projectCwd, '.claude', 'settings.local.json');
|
||||||
|
try {
|
||||||
|
const added = await this.addPermissionRulesToSettings(
|
||||||
|
settingsPath,
|
||||||
|
[...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES],
|
||||||
|
'allow'
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`[${teamName}] Seeded teammate operational MCP rules in ${settingsPath} (${added} added)`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`[${teamName}] Failed to seed teammate operational MCP rules: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,7 @@ const DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'tom',
|
name: 'tom',
|
||||||
roleSelection: 'researcher',
|
roleSelection: 'developer',
|
||||||
workflow:
|
|
||||||
'Research topics, gather information, and analyze relevant sources. Investigate questions, explore options, and provide detailed findings with clear summaries for the team.',
|
|
||||||
},
|
},
|
||||||
{ name: 'bob', roleSelection: 'developer' },
|
{ name: 'bob', roleSelection: 'developer' },
|
||||||
{ name: 'jack', roleSelection: 'developer' },
|
{ name: 'jack', roleSelection: 'developer' },
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,15 @@ export function extractToolPreview(
|
||||||
case 'WebSearch':
|
case 'WebSearch':
|
||||||
return typeof input.query === 'string' ? truncateStr(input.query, 40) : undefined;
|
return typeof input.query === 'string' ? truncateStr(input.query, 40) : undefined;
|
||||||
default: {
|
default: {
|
||||||
const v = input.name ?? input.path ?? input.file ?? input.query ?? input.command;
|
const v =
|
||||||
|
input.subject ??
|
||||||
|
input.name ??
|
||||||
|
input.description ??
|
||||||
|
input.prompt ??
|
||||||
|
input.path ??
|
||||||
|
input.file ??
|
||||||
|
input.query ??
|
||||||
|
input.command;
|
||||||
return typeof v === 'string' ? truncateStr(v, 50) : undefined;
|
return typeof v === 'string' ? truncateStr(v, 50) : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
src/types/agent-teams-controller.d.ts
vendored
26
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -93,9 +93,35 @@ declare module 'agent-teams-controller' {
|
||||||
buildProcessProtocolText(teamName: string): string;
|
buildProcessProtocolText(teamName: string): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AgentTeamsMcpToolGroupId =
|
||||||
|
| 'task'
|
||||||
|
| 'kanban'
|
||||||
|
| 'review'
|
||||||
|
| 'message'
|
||||||
|
| 'process'
|
||||||
|
| 'runtime'
|
||||||
|
| 'crossTeam';
|
||||||
|
|
||||||
|
export interface AgentTeamsMcpToolGroup {
|
||||||
|
id: AgentTeamsMcpToolGroupId;
|
||||||
|
teammateOperational: boolean;
|
||||||
|
toolNames: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
||||||
|
|
||||||
export const agentBlocks: AgentBlocksApi;
|
export const agentBlocks: AgentBlocksApi;
|
||||||
|
|
||||||
export const protocols: ProtocolsApi;
|
export const protocols: ProtocolsApi;
|
||||||
|
export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[];
|
||||||
|
export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges top-level user MCP with generated agent-teams config', async () => {
|
it('keeps generated team MCP config minimal and does not inline top-level user MCP', async () => {
|
||||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
|
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
|
||||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
|
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
|
||||||
createdDirs.push(homeDir, projectDir);
|
createdDirs.push(homeDir, projectDir);
|
||||||
|
|
@ -223,19 +223,9 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
mcpServers: Record<string, { command?: string; args?: string[]; type?: string; url?: string }>;
|
mcpServers: Record<string, { command?: string; args?: string[]; type?: string; url?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(Object.keys(parsed.mcpServers).sort()).toEqual([
|
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
|
||||||
'agent-teams',
|
expect(parsed.mcpServers.globalOnly).toBeUndefined();
|
||||||
'duplicateServer',
|
expect(parsed.mcpServers.duplicateServer).toBeUndefined();
|
||||||
'globalOnly',
|
|
||||||
]);
|
|
||||||
expect(parsed.mcpServers.globalOnly).toMatchObject({
|
|
||||||
type: 'http',
|
|
||||||
url: 'https://global.example.com/mcp',
|
|
||||||
});
|
|
||||||
expect(parsed.mcpServers.duplicateServer).toMatchObject({
|
|
||||||
type: 'http',
|
|
||||||
url: 'https://global.example.com/duplicate',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not inline project MCP config to preserve native Claude precedence', async () => {
|
it('does not inline project MCP config to preserve native Claude precedence', async () => {
|
||||||
|
|
@ -270,7 +260,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
|
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generated agent-teams server overrides same-named user MCP entry', async () => {
|
it('generated agent-teams server ignores same-named user MCP entry', async () => {
|
||||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
|
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
|
||||||
createdDirs.push(homeDir);
|
createdDirs.push(homeDir);
|
||||||
mockHomeDir = homeDir;
|
mockHomeDir = homeDir;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
||||||
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { spawnCli } from '@main/utils/childProcess';
|
import { spawnCli } from '@main/utils/childProcess';
|
||||||
|
import { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES } from 'agent-teams-controller';
|
||||||
|
|
||||||
function allowConsoleLogs() {
|
function allowConsoleLogs() {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
@ -324,4 +325,160 @@ describe('TeamProvisioningService', () => {
|
||||||
run.timeoutHandle = null;
|
run.timeoutHandle = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pre-seeds teammate operational MCP permissions before createTeam spawn', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
|
vi.mocked(spawnCli).mockImplementation(() => {
|
||||||
|
throw new Error('spawn EINVAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
const mcpConfigBuilder = {
|
||||||
|
writeConfigFile: vi.fn(async () => '/mock/mcp-config-create.json'),
|
||||||
|
removeConfigFile: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const membersMetaStore = {
|
||||||
|
writeMembers: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const teamMetaStore = {
|
||||||
|
writeMeta: vi.fn(async () => {}),
|
||||||
|
deleteMeta: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
membersMetaStore as any,
|
||||||
|
undefined,
|
||||||
|
mcpConfigBuilder as any,
|
||||||
|
teamMetaStore as any
|
||||||
|
);
|
||||||
|
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||||
|
env: { ANTHROPIC_API_KEY: 'test' },
|
||||||
|
authSource: 'anthropic_api_key',
|
||||||
|
}));
|
||||||
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName: 'seeded-team',
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
members: [{ name: 'alice' }],
|
||||||
|
skipPermissions: false,
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
).rejects.toThrow('spawn EINVAL');
|
||||||
|
|
||||||
|
const settingsPath = path.join(tempClaudeRoot, '.claude', 'settings.local.json');
|
||||||
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||||
|
permissions?: { allow?: string[] };
|
||||||
|
};
|
||||||
|
expect(settings.permissions?.allow).toEqual(
|
||||||
|
expect.arrayContaining([...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES])
|
||||||
|
);
|
||||||
|
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop');
|
||||||
|
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands teammate permission suggestions to the operational tool set only', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const svc = new TeamProvisioningService(
|
||||||
|
{
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
projectPath: tempClaudeRoot,
|
||||||
|
members: [{ cwd: tempClaudeRoot }],
|
||||||
|
})),
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await (svc as any).respondToTeammatePermission(
|
||||||
|
{ teamName: 'ops-team' },
|
||||||
|
'alice',
|
||||||
|
'req-1',
|
||||||
|
true,
|
||||||
|
undefined,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'addRules',
|
||||||
|
behavior: 'allow',
|
||||||
|
destination: 'localSettings',
|
||||||
|
rules: [{ toolName: 'mcp__agent-teams__task_get' }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const settingsPath = path.join(tempClaudeRoot, '.claude', 'settings.local.json');
|
||||||
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||||
|
permissions?: { allow?: string[] };
|
||||||
|
};
|
||||||
|
expect(settings.permissions?.allow).toEqual(
|
||||||
|
expect.arrayContaining([...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES])
|
||||||
|
);
|
||||||
|
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop');
|
||||||
|
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not broaden admin/runtime teammate permission suggestions', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const svc = new TeamProvisioningService(
|
||||||
|
{
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
projectPath: tempClaudeRoot,
|
||||||
|
members: [{ cwd: tempClaudeRoot }],
|
||||||
|
})),
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await (svc as any).respondToTeammatePermission(
|
||||||
|
{ teamName: 'ops-team' },
|
||||||
|
'alice',
|
||||||
|
'req-2',
|
||||||
|
true,
|
||||||
|
undefined,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'addRules',
|
||||||
|
behavior: 'allow',
|
||||||
|
destination: 'localSettings',
|
||||||
|
rules: [{ toolName: 'mcp__agent-teams__team_stop' }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const settingsPath = path.join(tempClaudeRoot, '.claude', 'settings.local.json');
|
||||||
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||||
|
permissions?: { allow?: string[] };
|
||||||
|
};
|
||||||
|
expect(settings.permissions?.allow).toEqual(['mcp__agent-teams__team_stop']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a non-alarming cloud delay message before 2 minutes of silence', () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
|
expect((svc as any).buildStallProgressMessage(90, '1m 30s')).toBe(
|
||||||
|
'Waiting on Cloud response for 1m 30s — logs can be delayed, this is still OK'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(svc as any).buildStallWarningText(90, {
|
||||||
|
request: { model: 'sonnet' },
|
||||||
|
})
|
||||||
|
).toContain('Logs can sometimes show up after 1-1.5 minutes, and that is still okay.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a cloud wait as unusual after 2 minutes of silence', () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
|
expect((svc as any).buildStallProgressMessage(120, '2m')).toBe(
|
||||||
|
'Still waiting on Cloud response for 2m — this is unusual'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(svc as any).buildStallWarningText(120, {
|
||||||
|
request: { model: 'sonnet' },
|
||||||
|
})
|
||||||
|
).toContain('but no logs for 2m is already unusual.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue