diff --git a/.azdo/pipelines/azure-dev.yml b/.azdo/pipelines/azure-dev.yml index e852f1810..44dc18f8a 100644 --- a/.azdo/pipelines/azure-dev.yml +++ b/.azdo/pipelines/azure-dev.yml @@ -9,7 +9,6 @@ trigger: - src/* - infra/* - azure.yaml - - azure_custom.yaml - .azdo/pipelines/azure-dev.yml exclude: - '*.md' diff --git a/.github/workflows/deploy-waf.yml b/.github/workflows/deploy-waf.yml index 5ea661fc5..02c2e78a4 100644 --- a/.github/workflows/deploy-waf.yml +++ b/.github/workflows/deploy-waf.yml @@ -12,7 +12,6 @@ on: - 'src/**' - 'infra/**' - 'azure.yaml' - - 'azure_custom.yaml' - '.github/workflows/deploy-waf.yml' schedule: - cron: "0 11,23 * * *" # Runs at 11:00 AM and 11:00 PM GMT diff --git a/.github/workflows/docker-build-and-push.yml b/.github/workflows/docker-build-and-push.yml index 8ed5555b8..5d53c14a5 100644 --- a/.github/workflows/docker-build-and-push.yml +++ b/.github/workflows/docker-build-and-push.yml @@ -18,7 +18,6 @@ on: - 'infra/scripts/**' - '.github/workflows/deploy.yml' - 'azure.yaml' - - 'azure_custom.yaml' pull_request: types: - opened @@ -41,7 +40,6 @@ on: - 'infra/scripts/**' - '.github/workflows/deploy.yml' - 'azure.yaml' - - 'azure_custom.yaml' workflow_dispatch: permissions: diff --git a/azure.yaml b/azure.yaml index 831d8cd16..59eda20ec 100644 --- a/azure.yaml +++ b/azure.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json name: multi-agent-custom-automation-engine-solution-accelerator -# metadata: -# template: multi-agent-custom-automation-engine-solution-accelerator@1.0 +metadata: + template: multi-agent-custom-automation-engine-solution-accelerator@1.0 requiredVersions: azd: '>= 1.18.0 != 1.23.9' hooks: diff --git a/azure_custom.yaml b/azure_custom.yaml deleted file mode 100644 index f836d2992..000000000 --- a/azure_custom.yaml +++ /dev/null @@ -1,108 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json -name: multi-agent-custom-automation-engine-solution-accelerator -metadata: - template: multi-agent-custom-automation-engine-solution-accelerator@1.0 -requiredVersions: - azd: '>= 1.18.0 != 1.23.9' - -services: - backend: - project: ./src/backend - language: py - host: containerapp - docker: - path: ./Dockerfile.NoCache - image: backend - registry: ${AZURE_CONTAINER_REGISTRY_ENDPOINT} - remoteBuild: true - - mcp: - project: ./src/mcp_server - language: py - host: containerapp - docker: - image: mcp - registry: ${AZURE_CONTAINER_REGISTRY_ENDPOINT} - remoteBuild: true - - frontend: - project: ./src/App - language: py - host: appservice - dist: ./dist - hooks: - prepackage: - windows: - shell: pwsh - run: ../../infra/scripts/build/package_frontend.ps1 - interactive: true - continueOnError: false - posix: - shell: sh - run: bash ../../infra/scripts/build/package_frontend.sh - interactive: true - continueOnError: false - -hooks: - postdeploy: - windows: - run: | - # Detect if running in Git Bash or similar Bash environment - if ($env:SHELL -like "*bash*" -or $env:MSYSTEM) { - # Running in Git Bash/MSYS2 environment - Write-Host "" - Write-Host "===============================================================" -ForegroundColor Yellow - Write-Host " POST-DEPLOYMENT STEPS (Bash)" -ForegroundColor Green - Write-Host "===============================================================" -ForegroundColor Yellow - Write-Host "" - - Write-Host " Upload Team Configurations, index sample data and create the Knowledge base from content Packs" -ForegroundColor White - Write-Host " πŸ‘‰ Run the following command in Bash:" -ForegroundColor White - Write-Host " bash infra/scripts/post-provision/post_deploy.sh" -ForegroundColor Cyan - Write-Host "" - - Write-Host "🌐 Access your deployed Frontend application at:" -ForegroundColor Green - Write-Host " https://$env:webSiteDefaultHostname" -ForegroundColor Cyan - Write-Host "" - } else { - # Running in PowerShell - Write-Host "" - Write-Host "===============================================================" -ForegroundColor Yellow - Write-Host " POST-DEPLOYMENT STEP (PowerShell) " -ForegroundColor Green - Write-Host "===============================================================" -ForegroundColor Yellow - Write-Host "" - - Write-Host " Upload Team Configurations, index sample data and create the Knowledge base from content Packs" -ForegroundColor White - Write-Host " πŸ‘‰ Run the following command in PowerShell:" -ForegroundColor White - Write-Host " infra\scripts\post-provision\post_deploy.ps1" -ForegroundColor Cyan - Write-Host "" - - Write-Host "🌐 Access your deployed Frontend application at:" -ForegroundColor Green - Write-Host " https://$env:webSiteDefaultHostname" -ForegroundColor Cyan - Write-Host "" - } - - shell: pwsh - interactive: true - posix: - run: | - Blue='\033[0;34m' - Green='\033[0;32m' - Yellow='\033[1;33m' - NC='\033[0m' - - printf "\n" - - printf "${Yellow}===============================================================\n" - printf "${Green} POST-DEPLOYMENT STEPS (Bash)\n" - printf "${Yellow}===============================================================${NC}\n\n" - - printf "Upload Team Configurations, index sample data and create the Knowledge base from content Packs:\n" - printf " πŸ‘‰ Run the following command in Bash:\n" - printf " ${Blue}bash infra/scripts/post-provision/post_deploy.sh${NC}\n\n" - - printf "🌐 Access your deployed Frontend application at:\n" - printf " ${Blue}https://%s${NC}\n\n" "$webSiteDefaultHostname" - - shell: sh - interactive: true \ No newline at end of file diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 3690f4b00..a833e2d45 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -354,7 +354,7 @@ The `azd up` deployment output includes a ready-to-use bash script command. Look - **For Bash (Linux/macOS/WSL):** ```bash - bash ./infra/scripts/build_and_push_images.sh + bash ./infra/scripts/post-provision/build_and_push_images.sh ``` - **For PowerShell (Windows):** ```powershell @@ -399,18 +399,18 @@ The images are **built remotely in ACR** using `az acr build`, so no local Docke Once both scripts complete, access your deployed frontend application at the URL printed by the `postdeploy` hook (`https://`), or retrieve it from the Azure Portal as described in [Step 4.3](#43-get-application-url). -### 5.3 Configure Authentication (Optional) +### 5.4 Configure Authentication (Optional) 1. Follow [App Authentication Configuration](./azure_app_service_auth_setup.md) 2. Wait up to 10 minutes for authentication changes to take effect -### 5.4 Verify Deployment +### 5.5 Verify Deployment 1. Access your application using the URL from Step 4.3 2. Confirm the application loads successfully -### 5.5 Test the Application +### 5.6 Test the Application **Quick Test Steps:** @@ -551,30 +551,3 @@ Now that your deployment is complete and tested, explore these resources to enha - πŸ› **Issues:** Check [Troubleshooting Guide](./TroubleShootingSteps.md) - πŸ’¬ **Support:** Review [Support Guidelines](../SUPPORT.md) - πŸ”§ **Development:** See [Contributing Guide](../CONTRIBUTING.md) - -## Advanced: Deploy Local Changes - -If you've made local modifications to the code and want to deploy them to Azure, follow these steps to swap the configuration files: - -> **Note:** To set up and run the application locally for development, see the [Local Development Setup Guide](./LocalDevelopmentSetup.md). - -### Step 1: Rename Azure Configuration Files - -**In the root directory:** -1. Rename `azure.yaml` to `azure_custom2.yaml` -2. Rename `azure_custom.yaml` to `azure.yaml` - -### Step 2: Rename Infrastructure Files - -**In the `infra` directory:** -1. Rename `main.bicep` to `main_custom2.bicep` -2. Rename `main_custom.bicep` to `main.bicep` - -### Step 3: Deploy Changes - -Run the deployment command: -```shell -azd up -``` - -> **Note:** These custom files are configured to deploy your local code changes instead of pulling from the GitHub repository. diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep deleted file mode 100644 index c5f49745e..000000000 --- a/infra/main_custom.bicep +++ /dev/null @@ -1,427 +0,0 @@ -// ============================================================================ -// main_custom.bicep -// Description: Bicep file for deploying the Multi-Agent Custom Automation Engine solution with custom code. -// ============================================================================ -targetScope = 'resourceGroup' - -metadata name = 'Multi-Agent Custom Automation Engine - Deployment Router' -metadata description = 'This is the bicep file used for deploying the Multi-Agent Custom Automation Engine solution with custom code.' - -// ============================================================================ -// Routing Parameter -// ============================================================================ - -@allowed(['bicep']) -@description('Required. Deployment flavor: bicep (vanilla Bicep)') -param deploymentFlavor string - -// ============================================================================ -// Parameters β€” Core -// ============================================================================ - -@description('Optional. A unique application/solution name for all resources in this deployment. This should be 3-16 characters long.') -@minLength(3) -@maxLength(16) -param solutionName string = 'macae' - -@maxLength(5) -@description('Optional. A unique text value for the solution. This is used to ensure resource names are unique for global resources.') -param solutionUniqueText string = take(uniqueString(subscription().id, resourceGroup().name, solutionName), 5) - -@metadata({ - azd: { - type: 'location' - } -}) -@description('Required. Azure region for app, data, and monitoring resources.') -@allowed([ - 'australiaeast' - 'centralus' - 'eastasia' - 'eastus2' - 'japaneast' - 'northeurope' - 'southeastasia' - 'uksouth' -]) -param location string - -@allowed(['australiaeast', 'eastus2', 'francecentral', 'japaneast', 'norwayeast', 'swedencentral', 'uksouth', 'westus', 'westus3', 'polandcentral', 'uaenorth']) -@metadata({ - azd: { - type: 'location' - usageName: [ - 'OpenAI.GlobalStandard.gpt-5.4, 150' - 'OpenAI.GlobalStandard.gpt-5.4-mini, 100' - 'OpenAI.GlobalStandard.gpt-image-1.5, 5' - ] - } -}) -@description('Required. Location for Azure AI Services and Azure AI Foundry resources.') -param azureAiServiceLocation string - -// ============================================================================ -// Parameters β€” AI -// ============================================================================ - -@description('Optional. Name of the default GPT model deployment.') -param gptModelName string = 'gpt-5.4-mini' - -@description('Optional. Version of the default GPT model deployment.') -param gptModelVersion string = '2026-03-17' - -@allowed([ - 'Standard' - 'GlobalStandard' -]) -@description('Optional. Deployment type for the default GPT model deployment.') -param deploymentType string = 'GlobalStandard' - -@minValue(1) -@description('Optional. Capacity of the default GPT model deployment.') -param gptDeploymentCapacity int = 100 - -@description('Optional. Name of the larger GPT model deployment.') -param gpt5_4ModelName string = 'gpt-5.4' - -@description('Optional. Version of the larger GPT model deployment.') -param gpt5_4ModelVersion string = '2026-03-05' - -@minLength(1) -@allowed([ - 'Standard' - 'GlobalStandard' -]) -@description('Optional. Deployment type for the larger GPT model deployment.') -param gpt5_4ModelDeploymentType string = 'GlobalStandard' - -@minValue(1) -@description('Optional. Capacity of the larger GPT model deployment.') -param gpt5_4ModelCapacity int = 150 - -@minLength(1) -@description('Optional. Name of the image-generation model to deploy. Defaults to gpt-image-1.5.') -param gptImageModelName string = 'gpt-image-1.5' - -@description('Optional. Version of the image-generation model to deploy. Defaults to 2025-12-16.') -param gptImageModelVersion string = '2025-12-16' - -@minLength(1) -@allowed([ - 'Standard' - 'GlobalStandard' -]) -@description('Optional. GPT image model deployment type. Defaults to GlobalStandard.') -param gptImageModelDeploymentType string = 'GlobalStandard' - -@description('Optional. gpt-image-1.5 deployment capacity (RPM). Defaults to 5 to support concurrent marketing-image generation across multiple sessions.') -param gptImageModelCapacity int = 5 - -@description('Optional. Version of the Azure OpenAI service to deploy. Defaults to 2024-12-01-preview.') -param azureOpenaiAPIVersion string = '2024-12-01-preview' - -// ============================================================================ -// Parameters β€” Compute -// ============================================================================ - -@description('Optional. The Container Registry hostname where the docker images for the backend are located.') -param backendContainerRegistryHostname string = 'biabcontainerreg.azurecr.io' - -@description('Optional. The Container Image Name to deploy on the backend.') -param backendContainerImageName string = 'macaebackend' - -@description('Optional. The Container Image Tag to deploy on the backend.') -param backendContainerImageTag string = 'latest_v5' - -@description('Optional. The Container Registry hostname where the docker images for the frontend are located.') -param frontendContainerRegistryHostname string = 'biabcontainerreg.azurecr.io' - -@description('Optional. The Container Image Name to deploy on the frontend.') -param frontendContainerImageName string = 'macaefrontend' - -@description('Optional. The Container Image Tag to deploy on the frontend.') -param frontendContainerImageTag string = 'latest_v5' - -@description('Optional. The Container Registry hostname where the docker images for the MCP are located.') -param MCPContainerRegistryHostname string = 'biabcontainerreg.azurecr.io' - -@description('Optional. The Container Image Name to deploy on the MCP.') -param MCPContainerImageName string = 'macaemcp' - -@description('Optional. The Container Image Tag to deploy on the MCP.') -param MCPContainerImageTag string = 'latest_v5' - -// ============================================================================ -// Parameters β€” Existing Resources and Governance -// ============================================================================ - -@description('Optional. Resource ID of an existing Log Analytics Workspace.') -param existingLogAnalyticsWorkspaceId string = '' - -@description('Optional. Resource ID of an existing AI Foundry project.') -param existingFoundryProjectResourceId string = '' - -@description('Optional. Additional tags to apply to deployed resources.') -param tags object = {} - -// ============================================================================ -// Parameters β€” Data -// ============================================================================ - -@description('Optional. Blob container name for retail customer documents.') -param storageContainerNameRetailCustomer string = 'retail-dataset-customer' - -@description('Optional. Blob container name for retail order documents.') -param storageContainerNameRetailOrder string = 'retail-dataset-order' - -@description('Optional. Blob container name for RFP summary documents.') -param storageContainerNameRFPSummary string = 'rfp-summary-dataset' - -@description('Optional. Blob container name for RFP risk documents.') -param storageContainerNameRFPRisk string = 'rfp-risk-dataset' - -@description('Optional. Blob container name for RFP compliance documents.') -param storageContainerNameRFPCompliance string = 'rfp-compliance-dataset' - -@description('Optional. Blob container name for contract summary documents.') -param storageContainerNameContractSummary string = 'contract-summary-dataset' - -@description('Optional. Blob container name for contract risk documents.') -param storageContainerNameContractRisk string = 'contract-risk-dataset' - -@description('Optional. Blob container name for contract compliance documents.') -param storageContainerNameContractCompliance string = 'contract-compliance-dataset' - -// ============================================================================ -// Module: Vanilla Bicep Deployment -// ============================================================================ - -module bicepDeployment './bicep/main.bicep' = { - name: take('module.bicep.${solutionName}', 64) - params: { - solutionName: solutionName - solutionUniqueText: solutionUniqueText - location: location - azureAiServiceLocation: azureAiServiceLocation - gptModelName: gptModelName - gptModelVersion: gptModelVersion - deploymentType: deploymentType - gptDeploymentCapacity: gptDeploymentCapacity - gpt5_4ModelName: gpt5_4ModelName - gpt5_4ModelVersion: gpt5_4ModelVersion - gpt5_4ModelDeploymentType: gpt5_4ModelDeploymentType - gpt5_4ModelCapacity: gpt5_4ModelCapacity - gptImageModelName: gptImageModelName - gptImageModelVersion: gptImageModelVersion - gptImageModelDeploymentType: gptImageModelDeploymentType - gptImageModelCapacity: gptImageModelCapacity - azureOpenaiAPIVersion: azureOpenaiAPIVersion - backendContainerRegistryHostname: backendContainerRegistryHostname - backendContainerImageName: backendContainerImageName - backendContainerImageTag: backendContainerImageTag - frontendContainerRegistryHostname: frontendContainerRegistryHostname - frontendContainerImageName: frontendContainerImageName - frontendContainerImageTag: frontendContainerImageTag - MCPContainerRegistryHostname: MCPContainerRegistryHostname - MCPContainerImageName: MCPContainerImageName - MCPContainerImageTag: MCPContainerImageTag - existingLogAnalyticsWorkspaceId: existingLogAnalyticsWorkspaceId - existingFoundryProjectResourceId: existingFoundryProjectResourceId - tags: tags - storageContainerNameRetailCustomer: storageContainerNameRetailCustomer - storageContainerNameRetailOrder: storageContainerNameRetailOrder - storageContainerNameRFPSummary: storageContainerNameRFPSummary - storageContainerNameRFPRisk: storageContainerNameRFPRisk - storageContainerNameRFPCompliance: storageContainerNameRFPCompliance - storageContainerNameContractSummary: storageContainerNameContractSummary - storageContainerNameContractRisk: storageContainerNameContractRisk - storageContainerNameContractCompliance: storageContainerNameContractCompliance - isCustom: true - } -} - -// ============================================================================ -// Outputs β€” Coalesced from whichever flavor was deployed -// ============================================================================ - -@description('The resource group the resources were deployed into.') -output resourceGroupName string = bicepDeployment!.outputs.resourceGroupName - -@description('The default url of the website to connect to the Multi-Agent Custom Automation Engine solution.') -output webSiteDefaultHostname string = bicepDeployment!.outputs.webSiteDefaultHostname - -// Storage -@description('The blob service endpoint of the deployed storage account.') -output AZURE_STORAGE_BLOB_URL string = bicepDeployment!.outputs.AZURE_STORAGE_BLOB_URL - -@description('The name of the deployed storage account used for content pack datasets and runtime artifacts.') -output AZURE_STORAGE_ACCOUNT_NAME string = bicepDeployment!.outputs.AZURE_STORAGE_ACCOUNT_NAME - -// Azure AI Search -@description('The endpoint URL of the deployed Azure AI Search service.') -output AZURE_AI_SEARCH_ENDPOINT string = bicepDeployment!.outputs.AZURE_AI_SEARCH_ENDPOINT - -@description('The name of the deployed Azure AI Search service.') -output AZURE_AI_SEARCH_NAME string = bicepDeployment!.outputs.AZURE_AI_SEARCH_NAME - -// Cosmos DB -@description('The document endpoint of the deployed Cosmos DB account used for agent memory and session state.') -output COSMOSDB_ENDPOINT string = bicepDeployment!.outputs.COSMOSDB_ENDPOINT - -@description('The name of the Cosmos DB SQL database used by the backend.') -output COSMOSDB_DATABASE string = bicepDeployment!.outputs.COSMOSDB_DATABASE - -@description('The name of the Cosmos DB container used to persist agent memory.') -output COSMOSDB_CONTAINER string = bicepDeployment!.outputs.COSMOSDB_CONTAINER - -// Azure OpenAI -@description('The Azure OpenAI endpoint exposed by the AI Foundry account.') -output AZURE_OPENAI_ENDPOINT string = bicepDeployment!.outputs.AZURE_OPENAI_ENDPOINT - -@description('The default GPT chat-completion deployment name used by the backend.') -output AZURE_OPENAI_DEPLOYMENT_NAME string = bicepDeployment!.outputs.AZURE_OPENAI_DEPLOYMENT_NAME - -@description('The deployment name of the GPT-5.4 model used for Responsible AI / higher-quality completions.') -output AZURE_OPENAI_RAI_DEPLOYMENT_NAME string = bicepDeployment!.outputs.AZURE_OPENAI_RAI_DEPLOYMENT_NAME - -@description('The Azure OpenAI REST API version used by the backend SDK clients.') -output AZURE_OPENAI_API_VERSION string = bicepDeployment!.outputs.AZURE_OPENAI_API_VERSION -// AI / Foundry context -@description('The subscription ID hosting the AI Foundry / AI Services resource.') -output AZURE_AI_SUBSCRIPTION_ID string = bicepDeployment!.outputs.AZURE_AI_SUBSCRIPTION_ID - -@description('The resource group hosting the AI Foundry / AI Services resource.') -output AZURE_AI_RESOURCE_GROUP string = bicepDeployment!.outputs.AZURE_AI_RESOURCE_GROUP - -@description('The name of the Azure AI Foundry project used by the backend.') -output AZURE_AI_PROJECT_NAME string = bicepDeployment!.outputs.AZURE_AI_PROJECT_NAME - -@description('The application environment label propagated to runtime container settings.') -output APP_ENV string = bicepDeployment!.outputs.APP_ENV - -@description('The resource ID of the AI Foundry (AI Services) account backing this deployment.') -output AI_FOUNDRY_RESOURCE_ID string = bicepDeployment!.outputs.AI_FOUNDRY_RESOURCE_ID - -@description('The name of the deployed Cosmos DB account.') -output COSMOSDB_ACCOUNT_NAME string = bicepDeployment!.outputs.COSMOSDB_ACCOUNT_NAME - -@description('Alias for AZURE_AI_SEARCH_ENDPOINT β€” kept for backward compatibility with seed scripts and the backend.') -output AZURE_SEARCH_ENDPOINT string = bicepDeployment!.outputs.AZURE_SEARCH_ENDPOINT - -@description('The client ID of the user-assigned managed identity used by backend, MCP, and frontend workloads.') -output AZURE_CLIENT_ID string = bicepDeployment!.outputs.AZURE_CLIENT_ID - -@description('The Microsoft Entra ID tenant ID used for token acquisition by all workloads.') -output AZURE_TENANT_ID string = bicepDeployment!.outputs.AZURE_TENANT_ID - -@description('The default scope used when requesting tokens for Azure Cognitive Services / AI Services.') -output AZURE_COGNITIVE_SERVICES string = bicepDeployment!.outputs.AZURE_COGNITIVE_SERVICES - -@description('The deployment name of the model used by the orchestrator/manager agent.') -output ORCHESTRATOR_MODEL_NAME string = bicepDeployment!.outputs.ORCHESTRATOR_MODEL_NAME - -// MCP server -@description('The configured name of the MCP server exposed by the deployment.') -output MCP_SERVER_NAME string = bicepDeployment!.outputs.MCP_SERVER_NAME - -@description('The human-readable description of the MCP server exposed by the deployment.') -output MCP_SERVER_DESCRIPTION string = bicepDeployment!.outputs.MCP_SERVER_DESCRIPTION - -@description('JSON-serialized list of model deployment names supported by this deployment.') -output SUPPORTED_MODELS string = bicepDeployment!.outputs.SUPPORTED_MODELS - -@description('The base URL of the backend Container App (used by the frontend reverse proxy).') -output BACKEND_URL string = bicepDeployment!.outputs.BACKEND_URL - -@description('The endpoint of the AI Foundry project used by backend SDK clients.') -output AZURE_AI_PROJECT_ENDPOINT string = bicepDeployment!.outputs.AZURE_AI_PROJECT_ENDPOINT - -@description('The endpoint used by the AI Foundry agent runtime β€” same value as the project endpoint.') -output AZURE_AI_AGENT_ENDPOINT string = bicepDeployment!.outputs.AZURE_AI_AGENT_ENDPOINT - -@description('The name of the AI Foundry / AI Services account resource.') -output AI_SERVICE_NAME string = bicepDeployment!.outputs.AI_SERVICE_NAME -// Storage container names (per content pack dataset) -@description('Blob container name used to upload the retail customer dataset.') -output AZURE_STORAGE_CONTAINER_NAME_RETAIL_CUSTOMER string = bicepDeployment!.outputs.AZURE_STORAGE_CONTAINER_NAME_RETAIL_CUSTOMER - -@description('Blob container name used to upload the retail order dataset.') -output AZURE_STORAGE_CONTAINER_NAME_RETAIL_ORDER string = bicepDeployment!.outputs.AZURE_STORAGE_CONTAINER_NAME_RETAIL_ORDER - -@description('Blob container name used to upload the RFP summary dataset.') -output AZURE_STORAGE_CONTAINER_NAME_RFP_SUMMARY string = bicepDeployment!.outputs.AZURE_STORAGE_CONTAINER_NAME_RFP_SUMMARY - -@description('Blob container name used to upload the RFP risk dataset.') -output AZURE_STORAGE_CONTAINER_NAME_RFP_RISK string = bicepDeployment!.outputs.AZURE_STORAGE_CONTAINER_NAME_RFP_RISK - -@description('Blob container name used to upload the RFP compliance dataset.') -output AZURE_STORAGE_CONTAINER_NAME_RFP_COMPLIANCE string = bicepDeployment!.outputs.AZURE_STORAGE_CONTAINER_NAME_RFP_COMPLIANCE - -@description('Blob container name used to upload the contract summary dataset.') -output AZURE_STORAGE_CONTAINER_NAME_CONTRACT_SUMMARY string = bicepDeployment!.outputs.AZURE_STORAGE_CONTAINER_NAME_CONTRACT_SUMMARY - -@description('Blob container name used to upload the contract risk dataset.') -output AZURE_STORAGE_CONTAINER_NAME_CONTRACT_RISK string = bicepDeployment!.outputs.AZURE_STORAGE_CONTAINER_NAME_CONTRACT_RISK - -@description('Blob container name used to upload the contract compliance dataset.') -output AZURE_STORAGE_CONTAINER_NAME_CONTRACT_COMPLIANCE string = bicepDeployment!.outputs.AZURE_STORAGE_CONTAINER_NAME_CONTRACT_COMPLIANCE - -// AI Search index names (per content pack dataset) -@description('AI Search index name used by the retail customer knowledge base.') -output AZURE_AI_SEARCH_INDEX_NAME_RETAIL_CUSTOMER string = bicepDeployment!.outputs.AZURE_AI_SEARCH_INDEX_NAME_RETAIL_CUSTOMER - -@description('AI Search index name used by the retail order knowledge base.') -output AZURE_AI_SEARCH_INDEX_NAME_RETAIL_ORDER string = bicepDeployment!.outputs.AZURE_AI_SEARCH_INDEX_NAME_RETAIL_ORDER - -@description('AI Search index name used by the RFP summary knowledge base.') -output AZURE_AI_SEARCH_INDEX_NAME_RFP_SUMMARY string = bicepDeployment!.outputs.AZURE_AI_SEARCH_INDEX_NAME_RFP_SUMMARY - -@description('AI Search index name used by the RFP risk knowledge base.') -output AZURE_AI_SEARCH_INDEX_NAME_RFP_RISK string = bicepDeployment!.outputs.AZURE_AI_SEARCH_INDEX_NAME_RFP_RISK - -@description('AI Search index name used by the RFP compliance knowledge base.') -output AZURE_AI_SEARCH_INDEX_NAME_RFP_COMPLIANCE string = bicepDeployment!.outputs.AZURE_AI_SEARCH_INDEX_NAME_RFP_COMPLIANCE - -@description('AI Search index name used by the contract summary knowledge base.') -output AZURE_AI_SEARCH_INDEX_NAME_CONTRACT_SUMMARY string = bicepDeployment!.outputs.AZURE_AI_SEARCH_INDEX_NAME_CONTRACT_SUMMARY - -@description('AI Search index name used by the contract risk knowledge base.') -output AZURE_AI_SEARCH_INDEX_NAME_CONTRACT_RISK string = bicepDeployment!.outputs.AZURE_AI_SEARCH_INDEX_NAME_CONTRACT_RISK - -@description('AI Search index name used by the contract compliance knowledge base.') -output AZURE_AI_SEARCH_INDEX_NAME_CONTRACT_COMPLIANCE string = bicepDeployment!.outputs.AZURE_AI_SEARCH_INDEX_NAME_CONTRACT_COMPLIANCE - -@description('The deployment flavor that was used (e.g., bicep). Echoed back from the input parameter.') -output DEPLOYMENT_FLAVOR string = deploymentFlavor - -@description('The resource group name the resources were deployed into.') -output RESOURCE_GROUP_NAME string = resourceGroup().name - -// Container Registry Outputs -@description('The login server endpoint of the Azure Container Registry, when one was provisioned.') -output AZURE_CONTAINER_REGISTRY_ENDPOINT string? = bicepDeployment!.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT! - -@description('The name of the Azure Container Registry, when one was provisioned.') -output AZURE_CONTAINER_REGISTRY_NAME string? = bicepDeployment!.outputs.AZURE_CONTAINER_REGISTRY_NAME! - -// Image build & push outputs (consumed by build_and_push_images scripts) -@description('Name of the backend Container App.') -output BACKEND_CONTAINER_APP_NAME string = bicepDeployment!.outputs.BACKEND_CONTAINER_APP_NAME - -@description('Name of the MCP Container App.') -output MCP_CONTAINER_APP_NAME string = bicepDeployment!.outputs.MCP_CONTAINER_APP_NAME - -@description('Name of the frontend Web App.') -output FRONTEND_WEB_APP_NAME string = bicepDeployment!.outputs.FRONTEND_WEB_APP_NAME - -@description('Backend container image repository name.') -output BACKEND_IMAGE_NAME string = bicepDeployment!.outputs.BACKEND_IMAGE_NAME - -@description('Frontend container image repository name.') -output FRONTEND_IMAGE_NAME string = bicepDeployment!.outputs.FRONTEND_IMAGE_NAME - -@description('MCP container image repository name.') -output MCP_IMAGE_NAME string = bicepDeployment!.outputs.MCP_IMAGE_NAME - -@description('Port the frontend Web App container listens on.') -output FRONTEND_WEBSITES_PORT string = bicepDeployment!.outputs.FRONTEND_WEBSITES_PORT diff --git a/src/backend/orchestration/plan_review_helpers.py b/src/backend/orchestration/plan_review_helpers.py index e8ff9d4b4..8fb4b47b4 100644 --- a/src/backend/orchestration/plan_review_helpers.py +++ b/src/backend/orchestration/plan_review_helpers.py @@ -95,7 +95,6 @@ def get_magentic_prompt_kwargs( "is INVALID β€” regenerate it\nuntil every listed agent appears as a step.\n" ) - scope_policy = """ TEAM SCOPE POLICY (CRITICAL β€” EVALUATE THIS FIRST, BEFORE ANY OTHER RULE): diff --git a/src/tests/backend/api/test_router.py b/src/tests/backend/api/test_router.py new file mode 100644 index 000000000..16b72c3de --- /dev/null +++ b/src/tests/backend/api/test_router.py @@ -0,0 +1,763 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Tests for api/router.py. + +These tests import the *real* router module and patch its collaborators at the +module level (never via sys.modules), so they do not pollute the shared +interpreter state for other test files that import the same real modules. +""" + +import contextlib +import os +import sys +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# Ensure flat backend imports (models.messages etc.) inside router resolve. +_backend_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "backend") +) +if _backend_path not in sys.path: + sys.path.insert(0, _backend_path) + + +def _import_router(): + """Import the real router module despite shared-process mock pollution. + + Earlier-collected tests (e.g. agents/) replace flat modules such as + ``common.database`` with bare ``Mock()`` objects in ``sys.modules``. Those + are not packages, so the router's flat imports would fail. We install proper + package stubs for the flat namespaces the router walks and ``MagicMock`` + stand-ins for its heavy leaf dependencies, letting the lightweight message + model modules import for real (so FastAPI request/response validation uses + the genuine dataclasses/pydantic models). Afterwards ``sys.modules`` is + restored to its exact prior state so no other test file is affected. The + router's collaborators are patched per-test. + """ + def _realpkg(name): + module = ModuleType(name) + module.__path__ = [os.path.join(_backend_path, *name.split("."))] + sys.modules[name] = module + + packages = [ + "common", "common.models", "common.config", "common.database", + "common.utils", "orchestration", "orchestration.helper", "services", + "auth", "models", + ] + heavy_leaves = [ + "common.config.app_config", "common.database.database_factory", + "common.utils.event_utils", "common.utils.team_utils", + "orchestration.connection_config", "orchestration.orchestration_manager", + "services.plan_service", "services.team_service", "auth.auth_utils", + ] + # Leaf modules that MUST load for real so FastAPI sees genuine model classes. + force_real = ["common.models.messages", "models.messages", "models.plan_models"] + snapshot = dict(sys.modules) + try: + for pkg in packages: + _realpkg(pkg) + for leaf in heavy_leaves: + sys.modules[leaf] = MagicMock() + for name in force_real: + sys.modules.pop(name, None) + import backend.api.router as router # noqa: F401 + from fastapi import FastAPI + + # Build the app while the real message-model modules are importable, so + # FastAPI resolves the route signatures against the genuine models. + app = FastAPI() + app.include_router(router.app_router) + return router, app + finally: + router_mod_obj = sys.modules.get("backend.api.router") + for key in list(sys.modules): + if key not in snapshot and not key.startswith("backend"): + del sys.modules[key] + for key, value in snapshot.items(): + sys.modules[key] = value + if router_mod_obj is not None: + sys.modules["backend.api.router"] = router_mod_obj + + +router_mod, _app = _import_router() +from fastapi.testclient import TestClient # noqa: E402 + + +# --------------------------------------------------------------------------- +# Fixture: TestClient with all collaborators mocked +# --------------------------------------------------------------------------- +@pytest.fixture +def rt(monkeypatch): + """Patch every collaborator referenced from the router namespace.""" + store = MagicMock() + store.get_plan_by_plan_id = AsyncMock(return_value=None) + store.get_current_team = AsyncMock(return_value=None) + store.get_team_by_id = AsyncMock(return_value=MagicMock()) + store.get_plan = AsyncMock(return_value=None) + store.get_agent_messages = AsyncMock(return_value=[]) + store.get_all_plans_by_team_id_status = AsyncMock(return_value=[]) + store.delete_current_team = AsyncMock() + store.add_plan = AsyncMock() + + database_factory = MagicMock() + database_factory.get_database = AsyncMock(return_value=store) + + team_service = MagicMock() + team_service.get_team_configuration = AsyncMock(return_value=None) + team_service.handle_team_selection = AsyncMock(return_value=MagicMock()) + team_service.get_all_team_configurations = AsyncMock(return_value=[]) + team_service.delete_team_configuration = AsyncMock(return_value=True) + team_service.validate_team_models = AsyncMock(return_value=(True, [])) + team_service.validate_team_search_indexes = AsyncMock(return_value=(True, [])) + team_service.validate_and_parse_team_config = AsyncMock(return_value=MagicMock()) + team_service.save_team_configuration = AsyncMock(return_value="team-123") + team_service_cls = MagicMock(return_value=team_service) + + plan_service = MagicMock() + plan_service.handle_plan_approval = AsyncMock(return_value=True) + plan_service.handle_human_clarification = AsyncMock(return_value=True) + plan_service.handle_agent_messages = AsyncMock(return_value=True) + + orchestration_manager = MagicMock() + orchestration_manager.get_current_or_new_orchestration = AsyncMock() + orchestration_manager.return_value.run_orchestration = AsyncMock() + + connection_config = MagicMock() + connection_config.send_status_update_async = AsyncMock() + connection_config.close_connection = AsyncMock() + connection_config.add_connection = MagicMock() + connection_config.wait_for_clarification = AsyncMock(return_value="the answer") + + orchestration_config = MagicMock() + orchestration_config.wait_for_clarification = AsyncMock(return_value="the answer") + orchestration_config.approvals = {} + orchestration_config.clarifications = {} + orchestration_config.plans = {} + orchestration_config.active_tasks = {} + orchestration_config.get_current_orchestration = MagicMock(return_value=None) + orchestration_config.set_approval_result = MagicMock() + orchestration_config.set_clarification_result = MagicMock() + orchestration_config.set_clarification_pending = MagicMock() + + team_config = MagicMock() + + find_first_available_team = AsyncMock(return_value="team-abc") + rai_success = AsyncMock(return_value=True) + rai_validate_team_config = AsyncMock(return_value=(True, None)) + get_user = MagicMock(return_value={"user_principal_id": "user-1"}) + + monkeypatch.setattr(router_mod, "get_authenticated_user_details", get_user) + monkeypatch.setattr(router_mod, "DatabaseFactory", database_factory) + monkeypatch.setattr(router_mod, "TeamService", team_service_cls) + monkeypatch.setattr(router_mod, "PlanService", plan_service) + monkeypatch.setattr(router_mod, "OrchestrationManager", orchestration_manager) + monkeypatch.setattr(router_mod, "connection_config", connection_config) + monkeypatch.setattr(router_mod, "orchestration_config", orchestration_config) + monkeypatch.setattr(router_mod, "team_config", team_config) + monkeypatch.setattr(router_mod, "track_event_if_configured", MagicMock()) + monkeypatch.setattr( + router_mod, "find_first_available_team", find_first_available_team + ) + monkeypatch.setattr(router_mod, "rai_success", rai_success) + monkeypatch.setattr(router_mod, "rai_validate_team_config", rai_validate_team_config) + + app = _app + client = TestClient(app) + + return SimpleNamespace( + client=client, + store=store, + database_factory=database_factory, + team_service=team_service, + team_service_cls=team_service_cls, + plan_service=plan_service, + orchestration_manager=orchestration_manager, + connection_config=connection_config, + orchestration_config=orchestration_config, + team_config=team_config, + find_first_available_team=find_first_available_team, + rai_success=rai_success, + rai_validate_team_config=rai_validate_team_config, + get_user=get_user, + ) + + +def _no_user(rt): + rt.get_user.return_value = {"user_principal_id": None} + + +# --------------------------------------------------------------------------- +# /init_team +# --------------------------------------------------------------------------- +class TestInitTeam: + def test_no_user(self, rt): + _no_user(rt) + resp = rt.client.get("/api/v4/init_team") + assert resp.status_code == 400 + + def test_no_teams_configured(self, rt): + rt.find_first_available_team.return_value = None + rt.store.get_current_team.return_value = None + resp = rt.client.get("/api/v4/init_team") + assert resp.status_code == 200 + body = resp.json() + assert body["requires_team_upload"] is True + + def test_first_available_team_used(self, rt): + rt.find_first_available_team.return_value = "team-abc" + rt.store.get_current_team.return_value = None + selected = MagicMock() + selected.team_id = "team-abc" + rt.team_service.handle_team_selection.return_value = selected + team_conf = MagicMock() + rt.team_service.get_team_configuration.return_value = team_conf + resp = rt.client.get("/api/v4/init_team") + assert resp.status_code == 200 + assert resp.json()["status"] == "Request started successfully" + + def test_current_team_used(self, rt): + current = MagicMock() + current.team_id = "team-current" + rt.store.get_current_team.return_value = current + rt.team_service.get_team_configuration.return_value = MagicMock() + resp = rt.client.get("/api/v4/init_team") + assert resp.status_code == 200 + assert resp.json()["team_id"] == "team-current" + + def test_team_configuration_missing_clears(self, rt): + current = MagicMock() + current.team_id = "team-current" + rt.store.get_current_team.return_value = current + rt.team_service.get_team_configuration.return_value = None + resp = rt.client.get("/api/v4/init_team") + assert resp.status_code == 200 + assert resp.json()["requires_team_upload"] is True + rt.store.delete_current_team.assert_awaited() + + def test_exception_returns_400(self, rt): + rt.database_factory.get_database = AsyncMock(side_effect=Exception("boom")) + resp = rt.client.get("/api/v4/init_team") + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# /process_request +# --------------------------------------------------------------------------- +class TestProcessRequest: + def _payload(self): + return {"session_id": "sess-1", "description": "do the thing"} + + def test_no_user(self, rt): + _no_user(rt) + resp = rt.client.post("/api/v4/process_request", json=self._payload()) + assert resp.status_code == 400 + + def test_team_not_found(self, rt): + rt.store.get_current_team.return_value = None + rt.store.get_team_by_id.return_value = None + resp = rt.client.post("/api/v4/process_request", json=self._payload()) + assert resp.status_code == 400 + + def test_rai_failure(self, rt): + rt.store.get_team_by_id.return_value = MagicMock() + rt.rai_success.return_value = False + resp = rt.client.post("/api/v4/process_request", json=self._payload()) + assert resp.status_code == 400 + + def test_success(self, rt): + team = MagicMock() + rt.store.get_team_by_id.return_value = team + current = MagicMock() + current.team_id = "team-x" + rt.store.get_current_team.return_value = current + rt.rai_success.return_value = True + resp = rt.client.post("/api/v4/process_request", json=self._payload()) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "Request started successfully" + assert body["plan_id"] + + def test_success_generates_session_id(self, rt): + rt.store.get_team_by_id.return_value = MagicMock() + rt.rai_success.return_value = True + resp = rt.client.post( + "/api/v4/process_request", json={"session_id": "", "description": "x"} + ) + assert resp.status_code == 200 + assert resp.json()["session_id"] + + +# --------------------------------------------------------------------------- +# /plan_approval +# --------------------------------------------------------------------------- +class TestPlanApproval: + def _payload(self, **kw): + data = {"m_plan_id": "m-1", "approved": True, "plan_id": "p-1", "feedback": "ok"} + data.update(kw) + return data + + def test_no_user(self, rt): + _no_user(rt) + resp = rt.client.post("/api/v4/plan_approval", json=self._payload()) + assert resp.status_code == 401 + + def test_approved_recorded(self, rt): + rt.orchestration_config.approvals = {"m-1": True} + plan = MagicMock() + plan.session_id = "sess-1" + rt.store.get_plan_by_plan_id.return_value = plan + resp = rt.client.post("/api/v4/plan_approval", json=self._payload()) + assert resp.status_code == 200 + assert resp.json()["status"] == "approval recorded" + rt.orchestration_config.set_approval_result.assert_called_once() + + def test_rejected_recorded(self, rt): + rt.orchestration_config.approvals = {"m-1": True} + rt.store.get_plan_by_plan_id.return_value = None + resp = rt.client.post( + "/api/v4/plan_approval", json=self._payload(approved=False) + ) + assert resp.status_code == 200 + + def test_no_active_plan(self, rt): + # The 404 raised in the else-branch is caught by the surrounding + # `except Exception` block and surfaced as a 500 by the endpoint. + rt.orchestration_config.approvals = {} + resp = rt.client.post("/api/v4/plan_approval", json=self._payload()) + assert resp.status_code == 500 + + def test_plan_service_value_error(self, rt): + rt.orchestration_config.approvals = {"m-1": True} + rt.plan_service.handle_plan_approval = AsyncMock(side_effect=ValueError("bad")) + resp = rt.client.post("/api/v4/plan_approval", json=self._payload()) + assert resp.status_code == 200 + + def test_plan_service_generic_error(self, rt): + rt.orchestration_config.approvals = {"m-1": True} + rt.plan_service.handle_plan_approval = AsyncMock(side_effect=Exception("boom")) + resp = rt.client.post("/api/v4/plan_approval", json=self._payload()) + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# /clarification/ask +# --------------------------------------------------------------------------- +class TestClarificationAsk: + def test_missing_fields(self, rt): + resp = rt.client.post("/api/v4/clarification/ask", json={"question": ""}) + assert resp.status_code == 400 + + def test_success(self, rt): + rt.orchestration_config.wait_for_clarification.return_value = "answer!" + resp = rt.client.post( + "/api/v4/clarification/ask", + json={"question": "why?", "user_id": "user-1"}, + ) + assert resp.status_code == 200 + assert resp.json()["answer"] == "answer!" + + def test_timeout(self, rt): + import asyncio + + rt.orchestration_config.wait_for_clarification = AsyncMock( + side_effect=asyncio.TimeoutError() + ) + resp = rt.client.post( + "/api/v4/clarification/ask", + json={"question": "why?", "user_id": "user-1"}, + ) + assert resp.status_code == 200 + assert resp.json()["answer"] == "" + + def test_generic_error(self, rt): + rt.orchestration_config.wait_for_clarification = AsyncMock( + side_effect=Exception("boom") + ) + resp = rt.client.post( + "/api/v4/clarification/ask", + json={"question": "why?", "user_id": "user-1"}, + ) + assert resp.status_code == 200 + assert resp.json()["answer"] == "" + + +# --------------------------------------------------------------------------- +# /user_clarification +# --------------------------------------------------------------------------- +class TestUserClarification: + def _payload(self, **kw): + data = {"request_id": "r-1", "answer": "my answer", "plan_id": "p-1"} + data.update(kw) + return data + + def test_no_user(self, rt): + _no_user(rt) + resp = rt.client.post("/api/v4/user_clarification", json=self._payload()) + assert resp.status_code == 401 + + def test_team_not_found(self, rt): + rt.store.get_team_by_id.return_value = None + resp = rt.client.post("/api/v4/user_clarification", json=self._payload()) + assert resp.status_code == 400 + + def test_rai_failure(self, rt): + rt.store.get_team_by_id.return_value = MagicMock() + rt.rai_success.return_value = False + resp = rt.client.post("/api/v4/user_clarification", json=self._payload()) + assert resp.status_code == 400 + + def test_success(self, rt): + rt.store.get_team_by_id.return_value = MagicMock() + rt.rai_success.return_value = True + rt.orchestration_config.clarifications = {"r-1": True} + resp = rt.client.post("/api/v4/user_clarification", json=self._payload()) + assert resp.status_code == 200 + assert resp.json()["status"] == "clarification recorded" + + def test_no_active_clarification(self, rt): + rt.store.get_team_by_id.return_value = MagicMock() + rt.rai_success.return_value = True + rt.orchestration_config.clarifications = {} + resp = rt.client.post("/api/v4/user_clarification", json=self._payload()) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# /agent_message +# --------------------------------------------------------------------------- +class TestAgentMessage: + def _payload(self, **kw): + data = { + "plan_id": "p-1", + "agent": "My Agent", + "content": "hello", + "agent_type": "AI_Agent", + } + data.update(kw) + return data + + def test_no_user(self, rt): + _no_user(rt) + resp = rt.client.post("/api/v4/agent_message", json=self._payload()) + assert resp.status_code == 401 + + def test_success(self, rt): + plan = MagicMock() + plan.session_id = "sess-1" + rt.store.get_plan_by_plan_id.return_value = plan + resp = rt.client.post("/api/v4/agent_message", json=self._payload()) + assert resp.status_code == 200 + assert resp.json()["status"] == "message recorded" + + def test_plan_service_error(self, rt): + rt.plan_service.handle_agent_messages = AsyncMock(side_effect=Exception("boom")) + resp = rt.client.post("/api/v4/agent_message", json=self._payload()) + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# /upload_team_config +# --------------------------------------------------------------------------- +class TestUploadTeamConfig: + def _file(self, content=b'{"name": "t", "status": "active"}', name="team.json"): + return {"file": (name, content, "application/json")} + + def test_no_user(self, rt): + _no_user(rt) + resp = rt.client.post("/api/v4/upload_team_config", files=self._file()) + assert resp.status_code == 400 + + def test_non_json_file(self, rt): + resp = rt.client.post( + "/api/v4/upload_team_config", files=self._file(name="team.txt") + ) + assert resp.status_code == 400 + + def test_invalid_json(self, rt): + resp = rt.client.post( + "/api/v4/upload_team_config", files=self._file(content=b"not json") + ) + assert resp.status_code == 400 + + def test_rai_validation_failure(self, rt): + rt.rai_validate_team_config.return_value = (False, "unsafe content") + resp = rt.client.post("/api/v4/upload_team_config", files=self._file()) + assert resp.status_code == 400 + + def test_model_validation_failure(self, rt): + rt.rai_validate_team_config.return_value = (True, None) + rt.team_service.validate_team_models.return_value = (False, ["gpt-4"]) + resp = rt.client.post("/api/v4/upload_team_config", files=self._file()) + assert resp.status_code == 400 + + def test_search_validation_failure(self, rt): + rt.rai_validate_team_config.return_value = (True, None) + rt.team_service.validate_team_models.return_value = (True, []) + rt.team_service.validate_team_search_indexes.return_value = (False, ["idx err"]) + resp = rt.client.post("/api/v4/upload_team_config", files=self._file()) + assert resp.status_code == 400 + + def test_success(self, rt): + rt.rai_validate_team_config.return_value = (True, None) + rt.team_service.validate_team_models.return_value = (True, []) + rt.team_service.validate_team_search_indexes.return_value = (True, []) + team_conf = MagicMock() + team_conf.agents = [1] + team_conf.starting_tasks = [1] + team_conf.name = "MyTeam" + team_conf.model_dump.return_value = {"name": "MyTeam"} + rt.team_service.validate_and_parse_team_config.return_value = team_conf + rt.team_service.save_team_configuration.return_value = "team-999" + resp = rt.client.post("/api/v4/upload_team_config", files=self._file()) + assert resp.status_code == 200 + assert resp.json()["team_id"] == "team-999" + + def test_success_with_team_id(self, rt): + rt.team_service.validate_team_models.return_value = (True, []) + rt.team_service.validate_team_search_indexes.return_value = (True, []) + team_conf = MagicMock() + team_conf.agents = [] + team_conf.starting_tasks = [] + team_conf.name = "MyTeam" + team_conf.model_dump.return_value = {"name": "MyTeam"} + rt.team_service.validate_and_parse_team_config.return_value = team_conf + rt.team_service.save_team_configuration.return_value = "given-id" + resp = rt.client.post( + "/api/v4/upload_team_config?team_id=given-id", files=self._file() + ) + assert resp.status_code == 200 + + def test_parse_value_error(self, rt): + rt.team_service.validate_team_models.return_value = (True, []) + rt.team_service.validate_team_search_indexes.return_value = (True, []) + rt.team_service.validate_and_parse_team_config = AsyncMock( + side_effect=ValueError("bad config") + ) + resp = rt.client.post("/api/v4/upload_team_config", files=self._file()) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# /team_configs (GET all) +# --------------------------------------------------------------------------- +class TestGetTeamConfigs: + def test_no_user(self, rt): + _no_user(rt) + resp = rt.client.get("/api/v4/team_configs") + assert resp.status_code == 401 + + def test_success(self, rt): + c = MagicMock() + c.model_dump.return_value = {"id": "1"} + rt.team_service.get_all_team_configurations.return_value = [c] + resp = rt.client.get("/api/v4/team_configs") + assert resp.status_code == 200 + assert resp.json() == [{"id": "1"}] + + def test_error(self, rt): + rt.team_service.get_all_team_configurations = AsyncMock( + side_effect=Exception("boom") + ) + resp = rt.client.get("/api/v4/team_configs") + assert resp.status_code == 500 + + +# --------------------------------------------------------------------------- +# /team_configs/{team_id} +# --------------------------------------------------------------------------- +class TestGetTeamConfigById: + def test_no_user(self, rt): + _no_user(rt) + resp = rt.client.get("/api/v4/team_configs/t1") + assert resp.status_code == 401 + + def test_not_found(self, rt): + rt.team_service.get_team_configuration.return_value = None + resp = rt.client.get("/api/v4/team_configs/t1") + assert resp.status_code == 404 + + def test_success(self, rt): + conf = MagicMock() + conf.model_dump.return_value = {"id": "t1"} + rt.team_service.get_team_configuration.return_value = conf + resp = rt.client.get("/api/v4/team_configs/t1") + assert resp.status_code == 200 + assert resp.json() == {"id": "t1"} + + def test_error(self, rt): + rt.team_service.get_team_configuration = AsyncMock( + side_effect=Exception("boom") + ) + resp = rt.client.get("/api/v4/team_configs/t1") + assert resp.status_code == 500 + + +# --------------------------------------------------------------------------- +# DELETE /team_configs/{team_id} +# --------------------------------------------------------------------------- +class TestDeleteTeamConfig: + def test_no_user(self, rt): + _no_user(rt) + resp = rt.client.delete("/api/v4/team_configs/t1") + assert resp.status_code == 401 + + def test_not_found(self, rt): + rt.team_service.delete_team_configuration.return_value = False + resp = rt.client.delete("/api/v4/team_configs/t1") + assert resp.status_code == 404 + + def test_success(self, rt): + rt.team_service.delete_team_configuration.return_value = True + resp = rt.client.delete("/api/v4/team_configs/t1") + assert resp.status_code == 200 + assert resp.json()["team_id"] == "t1" + + def test_error(self, rt): + rt.team_service.delete_team_configuration = AsyncMock( + side_effect=Exception("boom") + ) + resp = rt.client.delete("/api/v4/team_configs/t1") + assert resp.status_code == 500 + + +# --------------------------------------------------------------------------- +# /select_team +# --------------------------------------------------------------------------- +class TestSelectTeam: + def test_no_user(self, rt): + _no_user(rt) + resp = rt.client.post("/api/v4/select_team", json={"team_id": "t1"}) + assert resp.status_code == 401 + + def test_missing_team_id(self, rt): + resp = rt.client.post("/api/v4/select_team", json={"team_id": ""}) + assert resp.status_code == 400 + + def test_team_not_found(self, rt): + rt.team_service.get_team_configuration.return_value = None + resp = rt.client.post("/api/v4/select_team", json={"team_id": "t1"}) + assert resp.status_code == 404 + + def test_selection_failed(self, rt): + conf = MagicMock() + conf.name = "TeamA" + rt.team_service.get_team_configuration.return_value = conf + rt.team_service.handle_team_selection.return_value = None + resp = rt.client.post("/api/v4/select_team", json={"team_id": "t1"}) + assert resp.status_code == 404 + + def test_success(self, rt): + conf = MagicMock() + conf.name = "TeamA" + conf.agents = [1, 2] + conf.description = "desc" + rt.team_service.get_team_configuration.return_value = conf + rt.team_service.handle_team_selection.return_value = MagicMock() + resp = rt.client.post("/api/v4/select_team", json={"team_id": "t1"}) + assert resp.status_code == 200 + assert resp.json()["team_id"] == "t1" + + def test_error(self, rt): + rt.team_service.get_team_configuration = AsyncMock( + side_effect=Exception("boom") + ) + resp = rt.client.post("/api/v4/select_team", json={"team_id": "t1"}) + assert resp.status_code == 500 + + +# --------------------------------------------------------------------------- +# /plans +# --------------------------------------------------------------------------- +class TestGetPlans: + def test_no_user(self, rt): + _no_user(rt) + resp = rt.client.get("/api/v4/plans") + assert resp.status_code == 400 + + def test_no_current_team(self, rt): + rt.store.get_current_team.return_value = None + resp = rt.client.get("/api/v4/plans") + assert resp.status_code == 200 + assert resp.json() == [] + + def test_success(self, rt): + current = MagicMock() + current.team_id = "t1" + rt.store.get_current_team.return_value = current + rt.store.get_all_plans_by_team_id_status.return_value = [] + resp = rt.client.get("/api/v4/plans") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# /plan +# --------------------------------------------------------------------------- +class TestGetPlanById: + def test_no_user(self, rt): + _no_user(rt) + resp = rt.client.get("/api/v4/plan?plan_id=p1") + assert resp.status_code == 400 + + def test_no_plan_id(self, rt): + resp = rt.client.get("/api/v4/plan") + assert resp.status_code == 500 + + def test_plan_not_found(self, rt): + rt.store.get_plan_by_plan_id.return_value = None + resp = rt.client.get("/api/v4/plan?plan_id=p1") + assert resp.status_code == 500 + + def test_success(self, rt): + plan = MagicMock() + plan.session_id = "sess-1" + plan.team_id = "t1" + plan.plan_id = "p1" + plan.m_plan = {"x": 1} + plan.streaming_message = "streaming" + rt.store.get_plan_by_plan_id.return_value = plan + rt.store.get_team_by_id.return_value = MagicMock() + rt.store.get_agent_messages.return_value = [] + resp = rt.client.get("/api/v4/plan?plan_id=p1") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# /images/{blob_name} +# --------------------------------------------------------------------------- +class TestGetGeneratedImage: + def test_storage_not_configured(self, rt, monkeypatch): + cfg = MagicMock() + cfg.AZURE_STORAGE_BLOB_URL = "" + monkeypatch.setattr(router_mod, "config", cfg) + resp = rt.client.get("/api/v4/images/pic.png") + assert resp.status_code == 503 + + def test_invalid_name(self, rt, monkeypatch): + cfg = MagicMock() + cfg.AZURE_STORAGE_BLOB_URL = "https://blob" + cfg.AZURE_STORAGE_IMAGES_CONTAINER = "images" + monkeypatch.setattr(router_mod, "config", cfg) + resp = rt.client.get("/api/v4/images/evil!.png") + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# WebSocket /socket/{process_id} +# --------------------------------------------------------------------------- +class TestWebSocket: + def test_connect_and_disconnect(self, rt): + plan = MagicMock() + plan.session_id = "sess-1" + rt.store.get_plan_by_plan_id.return_value = plan + with rt.client.websocket_connect( + "/api/v4/socket/proc-1?user_id=user-1" + ) as ws: + ws.send_text("hello") + rt.connection_config.add_connection.assert_called_once() + + def test_connect_default_user(self, rt): + rt.store.get_plan_by_plan_id.return_value = None + with contextlib.suppress(Exception): + with rt.client.websocket_connect("/api/v4/socket/proc-2") as ws: + ws.close() diff --git a/src/tests/backend/callbacks/test_response_handlers.py b/src/tests/backend/callbacks/test_response_handlers.py index d44a712f5..2e7821acb 100644 --- a/src/tests/backend/callbacks/test_response_handlers.py +++ b/src/tests/backend/callbacks/test_response_handlers.py @@ -95,6 +95,7 @@ def __init__(self, text="", role="assistant", author_name=""): sys.modules['common.database.cosmosdb'] = Mock() sys.modules['common.database.database_factory'] = Mock() sys.modules['common.utils'] = Mock() +sys.modules['common.utils.markdown_utils'] = Mock(normalize_markdown_tables=Mock(side_effect=lambda text: text)) sys.modules['common.utils.team_utils'] = Mock() sys.modules['common.utils.event_utils'] = Mock() sys.modules['common.utils.otlp_tracing'] = Mock() @@ -601,10 +602,9 @@ async def test_streaming_callback_with_text(self): @pytest.mark.asyncio async def test_streaming_callback_no_text_with_contents(self): """Test streaming callback when update has no text but has contents with text. - - Note: The current implementation uses update.content (singular) when text is None, - not iterating through update.contents to concatenate text. This test verifies - the actual implementation behavior. + + When update.text is falsy, the implementation iterates update.contents and + concatenates the text of each content item that has one. """ mock_update = Mock() mock_update.text = None @@ -627,7 +627,7 @@ async def test_streaming_callback_no_text_with_contents(self): # Verify AgentMessageStreaming was created with concatenated content text mock_streaming.assert_called_once_with( agent_name="Agent 123", - content="Content from content attribute", + content="Content text 1Content text 2", is_final=False ) diff --git a/src/tests/backend/common/utils/test_agent_utils.py b/src/tests/backend/common/utils/test_agent_utils.py index 91d378eeb..b7e772676 100644 --- a/src/tests/backend/common/utils/test_agent_utils.py +++ b/src/tests/backend/common/utils/test_agent_utils.py @@ -5,8 +5,9 @@ """ import string +import sys import unittest -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch # Mock external dependencies at module level sys.modules['azure'] = Mock() diff --git a/src/tests/backend/common/utils/test_team_utils.py b/src/tests/backend/common/utils/test_team_utils.py index 41f4a236c..4a0eecb8a 100644 --- a/src/tests/backend/common/utils/test_team_utils.py +++ b/src/tests/backend/common/utils/test_team_utils.py @@ -201,10 +201,7 @@ async def test_create_rai_agent_success(self, mock_registry, mock_agent_class, m # Execute result = await create_RAI_agent(self.mock_team, self.mock_memory_store) - - # Verify team.model_copy() was called to create a copy - self.mock_team.model_copy.assert_called_once() - + # Verify agent creation mock_agent_class.assert_called_once() call_args = mock_agent_class.call_args @@ -219,10 +216,10 @@ async def test_create_rai_agent_success(self, mock_registry, mock_agent_class, m assert call_args[1]['team_config'] is self.mock_team assert call_args[1]['memory_store'] is self.mock_memory_store - # Verify the copied team configuration was updated (not the original) - assert self.mock_rai_team.team_id == "rai_team" - assert self.mock_rai_team.name == "RAI Team" - assert self.mock_rai_team.description == "Team responsible for Responsible AI checks" + # Verify the team configuration was updated in place (source mutates team directly) + assert self.mock_team.team_id == "rai_team" + assert self.mock_team.name == "RAI Team" + assert self.mock_team.description == "Team responsible for Responsible AI checks" # Verify agent initialization mock_agent.open.assert_called_once() diff --git a/src/tests/backend/config/test_agent_registry.py b/src/tests/backend/config/test_agent_registry.py new file mode 100644 index 000000000..971cd1fac --- /dev/null +++ b/src/tests/backend/config/test_agent_registry.py @@ -0,0 +1,137 @@ +"""Unit tests for backend.config.agent_registry.AgentRegistry. + +The registry is a pure-stdlib singleton (threading + weakref + asyncio) with +no external dependencies, so these tests exercise it directly. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from backend.config.agent_registry import AgentRegistry, agent_registry + + +class _Agent: + """Minimal stand-in agent (weak-referenceable).""" + + def __init__(self, name="A"): + self.agent_name = name + + +class TestRegisterUnregister: + def test_register_and_count(self): + reg = AgentRegistry() + a = _Agent("one") + reg.register_agent(a, user_id="u1") + assert reg.get_agent_count() == 1 + assert a in reg.get_all_agents() + assert reg._agent_metadata[id(a)]["user_id"] == "u1" + assert reg._agent_metadata[id(a)]["name"] == "one" + + def test_register_uses_name_attr_fallback(self): + reg = AgentRegistry() + obj = MagicMock(spec=["name"]) + obj.name = "viaName" + reg.register_agent(obj) + assert reg._agent_metadata[id(obj)]["name"] == "viaName" + + def test_register_handles_exception(self): + reg = AgentRegistry() + # int is not weak-referenceable -> WeakSet.add raises -> handled + reg.register_agent(12345) + assert reg.get_agent_count() == 0 + + def test_unregister_removes_metadata(self): + reg = AgentRegistry() + a = _Agent() + reg.register_agent(a) + reg.unregister_agent(a) + assert reg.get_agent_count() == 0 + assert id(a) not in reg._agent_metadata + + def test_unregister_unknown_is_noop(self): + reg = AgentRegistry() + reg.unregister_agent(_Agent()) # never registered + assert reg.get_agent_count() == 0 + + +class TestRegistryStatus: + def test_status_groups_by_type(self): + reg = AgentRegistry() + a, b = _Agent("a"), _Agent("b") # keep strong refs (WeakSet) + reg.register_agent(a) + reg.register_agent(b) + status = reg.get_registry_status() + assert status["total_agents"] == 2 + assert status["agent_types"]["_Agent"] == 2 + + +class TestCleanupAllAgents: + @pytest.mark.asyncio + async def test_cleanup_no_agents(self): + reg = AgentRegistry() + await reg.cleanup_all_agents() # returns early, no error + assert reg.get_agent_count() == 0 + + @pytest.mark.asyncio + async def test_cleanup_closes_async_agents(self): + reg = AgentRegistry() + agent = MagicMock() + agent.agent_name = "closer" + agent.close = AsyncMock() + reg.register_agent(agent) + await reg.cleanup_all_agents() + agent.close.assert_awaited_once() + assert reg.get_agent_count() == 0 + + @pytest.mark.asyncio + async def test_cleanup_sync_close(self): + reg = AgentRegistry() + agent = MagicMock() + agent.agent_name = "sync" + agent.close = MagicMock() # not a coroutine + reg.register_agent(agent) + await reg.cleanup_all_agents() + agent.close.assert_called_once() + + @pytest.mark.asyncio + async def test_cleanup_agent_without_close(self): + reg = AgentRegistry() + agent = _Agent("noclose") # no close() method + reg.register_agent(agent) + await reg.cleanup_all_agents() + assert reg.get_agent_count() == 0 + + @pytest.mark.asyncio + async def test_cleanup_handles_close_error(self): + reg = AgentRegistry() + agent = MagicMock() + agent.agent_name = "boom" + agent.close = AsyncMock(side_effect=RuntimeError("fail")) + reg.register_agent(agent) + await reg.cleanup_all_agents() # error captured by gather, not raised + assert reg.get_agent_count() == 0 + + +class TestSafeCloseAgent: + @pytest.mark.asyncio + async def test_safe_close_sync(self): + reg = AgentRegistry() + agent = MagicMock() + agent.name = "s" + agent.close = MagicMock() + await reg._safe_close_agent(agent) + agent.close.assert_called_once() + + @pytest.mark.asyncio + async def test_safe_close_swallows_error(self): + reg = AgentRegistry() + agent = MagicMock() + agent.name = "s" + agent.close = MagicMock(side_effect=ValueError("x")) + await reg._safe_close_agent(agent) # must not raise + + +def test_module_singleton_exists(): + assert isinstance(agent_registry, AgentRegistry) diff --git a/src/tests/backend/config/test_mcp_config.py b/src/tests/backend/config/test_mcp_config.py new file mode 100644 index 000000000..5836ab057 --- /dev/null +++ b/src/tests/backend/config/test_mcp_config.py @@ -0,0 +1,134 @@ +"""Unit tests for backend.config.mcp_config. + +Covers MCPConfig / KnowledgeBaseConfig dataclasses, their ``from_env`` +factories (domain URL rewriting, missing-env validation), header +generation, and the ``mcp_url`` property. The module's only external +dependency is the module-level ``config`` object (common.config.app_config), +which is patched per-test with a simple stand-in. +""" + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +import backend.config.mcp_config as mcp_config_module +from backend.config.mcp_config import ( + DOMAIN_ALLOWED_TOOLS, + KnowledgeBaseConfig, + MCPConfig, + VectorStoreConfig, +) + + +def _mcp_config_stub(**overrides): + base = dict( + MCP_SERVER_ENDPOINT="https://host/mcp", + MCP_SERVER_NAME="MCP", + MCP_SERVER_DESCRIPTION="desc", + AZURE_TENANT_ID="tenant", + AZURE_CLIENT_ID="client", + MCP_SERVER_CONNECTION_ID="conn-1", + AZURE_AI_SEARCH_ENDPOINT="https://search.example.net", + AZURE_AI_SEARCH_CONNECTION_NAME="", + ) + base.update(overrides) + return SimpleNamespace(**base) + + +class TestMCPConfigFromEnv: + def test_from_env_no_domain(self): + with patch.object(mcp_config_module, "config", _mcp_config_stub()): + cfg = MCPConfig.from_env() + assert cfg.url == "https://host/mcp" + assert cfg.name == "MCP" + assert cfg.description == "desc" + assert cfg.tenant_id == "tenant" + assert cfg.client_id == "client" + assert cfg.connection_id == "conn-1" + assert cfg.allowed_tools is None + + def test_from_env_domain_rewrites_mcp_suffix(self): + with patch.object(mcp_config_module, "config", _mcp_config_stub()): + cfg = MCPConfig.from_env(domain="hr") + assert cfg.url == "https://host/hr/mcp" + assert cfg.allowed_tools == DOMAIN_ALLOWED_TOOLS["hr"] + + def test_from_env_domain_without_mcp_suffix(self): + stub = _mcp_config_stub(MCP_SERVER_ENDPOINT="https://host/base/") + with patch.object(mcp_config_module, "config", stub): + cfg = MCPConfig.from_env(domain="tech_support") + assert cfg.url == "https://host/base/tech_support" + assert cfg.allowed_tools == DOMAIN_ALLOWED_TOOLS["tech_support"] + + def test_from_env_domain_unknown_has_no_allowed_tools(self): + with patch.object(mcp_config_module, "config", _mcp_config_stub()): + cfg = MCPConfig.from_env(domain="does_not_exist") + assert cfg.url == "https://host/does_not_exist/mcp" + assert cfg.allowed_tools is None + + def test_from_env_missing_env_raises(self): + stub = _mcp_config_stub(MCP_SERVER_ENDPOINT="") + with patch.object(mcp_config_module, "config", stub): + with pytest.raises(ValueError, match="missing required environment variables"): + MCPConfig.from_env() + + +class TestMCPConfigHeaders: + def test_get_headers_with_token(self): + cfg = MCPConfig() + headers = cfg.get_headers("abc") + assert headers["Content-Type"] == "application/json" + assert "Authorization" in headers + + def test_get_headers_without_token(self): + cfg = MCPConfig() + assert cfg.get_headers("") == {} + + +class TestVectorStoreConfig: + def test_defaults(self): + assert VectorStoreConfig().vector_store_name == "" + + +class TestKnowledgeBaseConfig: + def test_from_env_default_connection_name(self): + with patch.object(mcp_config_module, "config", _mcp_config_stub()): + kb = KnowledgeBaseConfig.from_env("mykb") + assert kb.knowledge_base_name == "mykb" + assert kb.search_endpoint == "https://search.example.net" + assert kb.search_connection_name == "mykb-mcp" + + def test_from_env_explicit_connection_name(self): + stub = _mcp_config_stub(AZURE_AI_SEARCH_CONNECTION_NAME="shared-conn") + with patch.object(mcp_config_module, "config", stub): + kb = KnowledgeBaseConfig.from_env("mykb") + assert kb.search_connection_name == "shared-conn" + + def test_from_env_missing_endpoint_raises(self): + stub = _mcp_config_stub(AZURE_AI_SEARCH_ENDPOINT="") + with patch.object(mcp_config_module, "config", stub): + with pytest.raises(ValueError): + KnowledgeBaseConfig.from_env("mykb") + + def test_from_env_missing_kb_name_raises(self): + with patch.object(mcp_config_module, "config", _mcp_config_stub()): + with pytest.raises(ValueError): + KnowledgeBaseConfig.from_env("") + + def test_mcp_url(self): + kb = KnowledgeBaseConfig( + knowledge_base_name="kb1", + search_endpoint="https://search.example.net/", + ) + assert kb.mcp_url == ( + "https://search.example.net/knowledgebases/kb1/mcp" + "?api-version=2025-11-01-preview" + ) + + +class TestDomainAllowedTools: + def test_known_domains_present(self): + for domain in ("hr", "tech_support", "marketing", "product", "image"): + assert domain in DOMAIN_ALLOWED_TOOLS + assert isinstance(DOMAIN_ALLOWED_TOOLS[domain], list) diff --git a/src/tests/backend/conftest.py b/src/tests/backend/conftest.py index b8415eb0d..a8ede50e1 100644 --- a/src/tests/backend/conftest.py +++ b/src/tests/backend/conftest.py @@ -4,6 +4,7 @@ This module handles proper test isolation and minimal external module mocking. """ +import importlib.util import os import sys from types import ModuleType @@ -37,14 +38,80 @@ def _setup_environment_variables(): os.environ.setdefault(key, value) +class _LazyClassMeta(type): + """Metaclass for fabricated stub classes. + + Allows arbitrary *class-level* attribute access (e.g. + ``MagenticOrchestrator._handle_response``) used by import-time monkey-patches + in ``patches/*.py``, while the class itself remains a real, instantiable type + usable as a base class or in ``Union`` type hints. Unknown attributes are + lazily created as ``MagicMock`` and cached on the class so they can be read + and reassigned. + """ + + def __getattr__(cls, name): + if name.startswith('__') and name.endswith('__'): + raise AttributeError(name) + value = MagicMock() + setattr(cls, name, value) + return value + + +def _stub_class(name, **attrs): + """Create a real (instantiable) stub class that tolerates arbitrary + class-attribute access via :class:`_LazyClassMeta`.""" + namespace = {'__init__': lambda self, *args, **kwargs: None} + namespace.update(attrs) + return _LazyClassMeta(name, (), namespace) + + +def _install_lazy_attrs(module): + """Make ``from module import `` succeed for names not explicitly + defined on a mock module. + + The modularity refactor imports a growing set of symbols from + ``agent_framework`` and ``agent_framework_orchestrations`` (e.g. + ``WorkflowRunState``, ``MagenticPlanReviewRequest``). Rather than enumerate + every one, install a PEP 562 module ``__getattr__`` that lazily fabricates a + real (instantiable) class for any missing attribute. Real classesβ€”not + ``MagicMock``β€”are required so names used as base classes or in ``Union`` + type hints don't raise at import time. + """ + _cache = {} + + def __getattr__(name): # noqa: N807 (PEP 562 module hook) + if name.startswith('__') and name.endswith('__'): + raise AttributeError(name) + if name not in _cache: + if name.isupper() or name.endswith('_PROMPT'): + # Module-level constants (e.g. ORCHESTRATOR_*_PROMPT) are used as + # string templates; fabricate a string rather than a class. + _cache[name] = 'mock_prompt_string' + else: + _cache[name] = _stub_class(name) + return _cache[name] + + module.__getattr__ = __getattr__ + + def _setup_agent_framework_mock(): """ Set up mock for agent_framework which is not a pip-installable package. This framework is used for Azure AI Agents and needs proper mocking. Uses ModuleType with real stub classes for names used in type annotations or as base classes, and MagicMock for everything else. + + Each ``agent_framework*`` distribution is stubbed independently and ONLY + when it is not actually installed (``find_spec(...) is None``). This keeps + real installs from being shadowed β€” installing mocks on top of a real + package would shadow internals they depend on (e.g. + ``agent_framework._clients``) and break imports β€” while still allowing + mixed environments where only some companion distributions are present. """ - if 'agent_framework' not in sys.modules: + if ( + importlib.util.find_spec('agent_framework') is None + and 'agent_framework' not in sys.modules + ): # Top-level: agent_framework mock_af = ModuleType('agent_framework') @@ -62,19 +129,17 @@ def _setup_agent_framework_mock(): 'WorkflowOutputEvent', ] for name in _class_names: - setattr(mock_af, name, type(name, (), { - '__init__': lambda self, *args, **kwargs: None, - })) + setattr(mock_af, name, _stub_class(name)) # Sub-module: agent_framework._types mock_af_types = ModuleType('agent_framework._types') - mock_af_types.ResponseStream = type('ResponseStream', (), {}) + mock_af_types.ResponseStream = _stub_class('ResponseStream') mock_af._types = mock_af_types sys.modules['agent_framework._types'] = mock_af_types # Sub-module: agent_framework.azure mock_af_azure = ModuleType('agent_framework.azure') - mock_af_azure.AzureOpenAIChatClient = type('AzureOpenAIChatClient', (), {}) + mock_af_azure.AzureOpenAIChatClient = _stub_class('AzureOpenAIChatClient') mock_af.azure = mock_af_azure # Sub-module: agent_framework._workflows._magentic @@ -83,7 +148,7 @@ def _setup_agent_framework_mock(): for name in [ 'MagenticContext', 'StandardMagenticManager', ]: - setattr(mock_af_magentic, name, type(name, (), {})) + setattr(mock_af_magentic, name, _stub_class(name)) for name in [ 'ORCHESTRATOR_FINAL_ANSWER_PROMPT', 'ORCHESTRATOR_PROGRESS_LEDGER_PROMPT', @@ -93,6 +158,7 @@ def _setup_agent_framework_mock(): setattr(mock_af_magentic, name, "mock_prompt_string") mock_af_workflows._magentic = mock_af_magentic mock_af._workflows = mock_af_workflows + _install_lazy_attrs(mock_af_magentic) # Sub-module: agent_framework._tools (provides the @tool decorator used # by tools/clarification_tool.py as @tool(approval_mode="always_require")). @@ -111,34 +177,36 @@ def _decorator(func): mock_af_tools.tool = _mock_tool mock_af._tools = mock_af_tools + _install_lazy_attrs(mock_af) + sys.modules['agent_framework'] = mock_af sys.modules['agent_framework.azure'] = mock_af_azure sys.modules['agent_framework._workflows'] = mock_af_workflows sys.modules['agent_framework._workflows._magentic'] = mock_af_magentic sys.modules['agent_framework._tools'] = mock_af_tools - if 'agent_framework_orchestrations' not in sys.modules: + if ( + importlib.util.find_spec('agent_framework_orchestrations') is None + and 'agent_framework_orchestrations' not in sys.modules + ): mock_af_orch = ModuleType('agent_framework_orchestrations') - mock_af_orch.MagenticBuilder = type('MagenticBuilder', (), { - '__init__': lambda self, *args, **kwargs: None, - 'build': lambda self: Mock(), - }) + mock_af_orch.MagenticBuilder = _stub_class( + 'MagenticBuilder', build=lambda self: Mock()) + _install_lazy_attrs(mock_af_orch) sys.modules['agent_framework_orchestrations'] = mock_af_orch mock_af_orch_base = ModuleType('agent_framework_orchestrations._base_group_chat_orchestrator') for name in ['GroupChatRequestSentEvent', 'GroupChatResponseReceivedEvent']: - setattr(mock_af_orch_base, name, type(name, (), {})) + setattr(mock_af_orch_base, name, _stub_class(name)) sys.modules['agent_framework_orchestrations._base_group_chat_orchestrator'] = mock_af_orch_base mock_af_orch_mag = ModuleType('agent_framework_orchestrations._magentic') for name in ['MagenticContext', 'MagenticProgressLedger']: - setattr(mock_af_orch_mag, name, type(name, (), {})) + setattr(mock_af_orch_mag, name, _stub_class(name)) # StandardMagenticManager needs a proper __init__ that accepts args/kwargs # because HumanApprovalMagenticManager calls super().__init__(agent, *args, **kwargs) setattr(mock_af_orch_mag, 'StandardMagenticManager', - type('StandardMagenticManager', (), { - '__init__': lambda self, *args, **kwargs: None - })) + _stub_class('StandardMagenticManager')) for name in [ 'ORCHESTRATOR_FINAL_ANSWER_PROMPT', 'ORCHESTRATOR_PROGRESS_LEDGER_PROMPT', @@ -146,13 +214,39 @@ def _decorator(func): 'ORCHESTRATOR_TASK_LEDGER_PLAN_UPDATE_PROMPT', ]: setattr(mock_af_orch_mag, name, 'mock_prompt_string') + _install_lazy_attrs(mock_af_orch_mag) sys.modules['agent_framework_orchestrations._magentic'] = mock_af_orch_mag - if 'agent_framework_azure_ai' not in sys.modules: + if ( + importlib.util.find_spec('agent_framework_azure_ai') is None + and 'agent_framework_azure_ai' not in sys.modules + ): mock_af_ai = ModuleType('agent_framework_azure_ai') - mock_af_ai.AzureAIClient = type('AzureAIClient', (), {}) + mock_af_ai.AzureAIClient = _stub_class('AzureAIClient') sys.modules['agent_framework_azure_ai'] = mock_af_ai + if ( + importlib.util.find_spec('agent_framework_foundry') is None + and 'agent_framework_foundry' not in sys.modules + ): + # agent_framework_foundry provides FoundryChatClient, imported by + # agents/agent_template.py and orchestration/orchestration_manager.py. + # It is not pip-installable in CI, so provide a stub only when the real + # distribution is genuinely absent (gated on find_spec above). + mock_af_foundry = ModuleType('agent_framework_foundry') + mock_af_foundry.FoundryChatClient = _stub_class('FoundryChatClient') + sys.modules['agent_framework_foundry'] = mock_af_foundry + + if ( + importlib.util.find_spec('agent_framework_openai') is None + and 'agent_framework_openai' not in sys.modules + ): + # agent_framework_openai provides OpenAIChatOptions, imported by + # agents/agent_template.py. + mock_af_openai = ModuleType('agent_framework_openai') + mock_af_openai.OpenAIChatOptions = _stub_class('OpenAIChatOptions') + sys.modules['agent_framework_openai'] = mock_af_openai + def _setup_azure_monitor_mock(): """Mock azure.monitor.opentelemetry which may not be installed.""" @@ -174,6 +268,9 @@ def _patch_azure_ai_projects_models(): 'AzureAISearchAgentTool', 'AzureAISearchToolResource', 'AISearchIndexResource', + 'CodeInterpreterTool', + 'FileSearchTool', + 'MCPTool', ] for name in missing_names: if not hasattr(models_mod, name): diff --git a/src/tests/backend/orchestration/test_connection_config.py b/src/tests/backend/orchestration/test_connection_config.py new file mode 100644 index 000000000..32a804ab7 --- /dev/null +++ b/src/tests/backend/orchestration/test_connection_config.py @@ -0,0 +1,347 @@ +"""Unit tests for backend.orchestration.connection_config. + +Covers OrchestrationConfig (approval/clarification event helpers), +ConnectionConfig (WebSocket registry + status broadcasting), and +TeamConfig. WebSockets are represented by AsyncMock/MagicMock. +""" + +import asyncio +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def _import_connection_config(): + """Import backend.orchestration.connection_config with the REAL flat + ``models.*`` / ``common.models.*`` packages, undoing any bare-Mock or + empty-ModuleType pollution installed by earlier test modules in the same + single-process collection run, then restore sys.modules exactly. + """ + snapshot = dict(sys.modules) + force_real = [ + "common", + "common.models", + "common.models.messages", + "models", + "models.messages", + "models.plan_models", + "backend.orchestration.connection_config", + ] + try: + for name in force_real: + sys.modules.pop(name, None) + import backend.orchestration.connection_config as cc # noqa: WPS433 + return cc + finally: + cc_mod = sys.modules.get("backend.orchestration.connection_config") + for key in list(sys.modules): + if key not in snapshot and not key.startswith("backend"): + sys.modules.pop(key, None) + sys.modules.update(snapshot) + if cc_mod is not None: + sys.modules["backend.orchestration.connection_config"] = cc_mod + + +_cc = _import_connection_config() +ConnectionConfig = _cc.ConnectionConfig +OrchestrationConfig = _cc.OrchestrationConfig +TeamConfig = _cc.TeamConfig +connection_config = _cc.connection_config +orchestration_config = _cc.orchestration_config +team_config = _cc.team_config + + +# ----------------------------------------------------------------------- # +# OrchestrationConfig +# ----------------------------------------------------------------------- # +class TestOrchestrationApproval: + def test_get_current_orchestration(self): + cfg = OrchestrationConfig() + cfg.orchestrations["u1"] = "wf" + assert cfg.get_current_orchestration("u1") == "wf" + assert cfg.get_current_orchestration("missing") is None + + def test_set_approval_pending_creates_and_resets(self): + cfg = OrchestrationConfig() + cfg.set_approval_pending("p1") + assert cfg.approvals["p1"] is None + ev = cfg._approval_events["p1"] + ev.set() + cfg.set_approval_pending("p1") # existing -> clear + assert not cfg._approval_events["p1"].is_set() + + def test_set_approval_result_triggers_event(self): + cfg = OrchestrationConfig() + cfg.set_approval_pending("p1") + cfg.set_approval_result("p1", True) + assert cfg.approvals["p1"] is True + assert cfg._approval_events["p1"].is_set() + + @pytest.mark.asyncio + async def test_wait_for_approval_already_decided(self): + cfg = OrchestrationConfig() + cfg.approvals["p1"] = True + assert await cfg.wait_for_approval("p1") is True + + @pytest.mark.asyncio + async def test_wait_for_approval_missing_raises_keyerror(self): + cfg = OrchestrationConfig() + with pytest.raises(KeyError): + await cfg.wait_for_approval("nope") + + @pytest.mark.asyncio + async def test_wait_for_approval_waits_then_returns(self): + cfg = OrchestrationConfig() + cfg.set_approval_pending("p1") + + async def approve(): + await asyncio.sleep(0.01) + cfg.set_approval_result("p1", True) + + task = asyncio.create_task(approve()) + result = await cfg.wait_for_approval("p1", timeout=1.0) + await task + assert result is True + + @pytest.mark.asyncio + async def test_wait_for_approval_timeout(self): + cfg = OrchestrationConfig() + cfg.set_approval_pending("p1") + with pytest.raises(asyncio.TimeoutError): + await cfg.wait_for_approval("p1", timeout=0.01) + assert "p1" not in cfg.approvals # cleaned up + + def test_cleanup_approval(self): + cfg = OrchestrationConfig() + cfg.set_approval_pending("p1") + cfg.cleanup_approval("p1") + assert "p1" not in cfg.approvals + assert "p1" not in cfg._approval_events + + +class TestOrchestrationClarification: + def test_set_clarification_pending_and_reset(self): + cfg = OrchestrationConfig() + cfg.set_clarification_pending("r1") + assert cfg.clarifications["r1"] is None + cfg._clarification_events["r1"].set() + cfg.set_clarification_pending("r1") + assert not cfg._clarification_events["r1"].is_set() + + def test_set_clarification_result(self): + cfg = OrchestrationConfig() + cfg.set_clarification_pending("r1") + cfg.set_clarification_result("r1", "answer") + assert cfg.clarifications["r1"] == "answer" + assert cfg._clarification_events["r1"].is_set() + + @pytest.mark.asyncio + async def test_wait_for_clarification_already_answered(self): + cfg = OrchestrationConfig() + cfg.clarifications["r1"] = "done" + assert await cfg.wait_for_clarification("r1") == "done" + + @pytest.mark.asyncio + async def test_wait_for_clarification_missing_keyerror(self): + cfg = OrchestrationConfig() + with pytest.raises(KeyError): + await cfg.wait_for_clarification("nope") + + @pytest.mark.asyncio + async def test_wait_for_clarification_waits(self): + cfg = OrchestrationConfig() + cfg.set_clarification_pending("r1") + + async def answer(): + await asyncio.sleep(0.01) + cfg.set_clarification_result("r1", "hi") + + task = asyncio.create_task(answer()) + result = await cfg.wait_for_clarification("r1", timeout=1.0) + await task + assert result == "hi" + + @pytest.mark.asyncio + async def test_wait_for_clarification_timeout(self): + cfg = OrchestrationConfig() + cfg.set_clarification_pending("r1") + with pytest.raises(asyncio.TimeoutError): + await cfg.wait_for_clarification("r1", timeout=0.01) + assert "r1" not in cfg.clarifications + + def test_cleanup_clarification(self): + cfg = OrchestrationConfig() + cfg.set_clarification_pending("r1") + cfg.cleanup_clarification("r1") + assert "r1" not in cfg.clarifications + assert "r1" not in cfg._clarification_events + + +# ----------------------------------------------------------------------- # +# ConnectionConfig +# ----------------------------------------------------------------------- # +class TestConnectionRegistry: + @pytest.mark.asyncio + async def test_add_connection_simple(self): + cc = ConnectionConfig() + ws = AsyncMock() + cc.add_connection("proc1", ws) + assert cc.get_connection("proc1") is ws + + @pytest.mark.asyncio + async def test_add_connection_with_user(self): + cc = ConnectionConfig() + ws = AsyncMock() + cc.add_connection("proc1", ws, user_id="u1") + assert cc.user_to_process["u1"] == "proc1" + + @pytest.mark.asyncio + async def test_add_connection_replaces_existing_process(self): + cc = ConnectionConfig() + old = AsyncMock() + cc.add_connection("proc1", old) + new = AsyncMock() + cc.add_connection("proc1", new) # triggers close of old via create_task + await asyncio.sleep(0) + assert cc.get_connection("proc1") is new + + @pytest.mark.asyncio + async def test_add_connection_closes_old_process_for_user(self): + cc = ConnectionConfig() + first = AsyncMock() + cc.add_connection("procA", first, user_id="u1") + second = AsyncMock() + cc.add_connection("procB", second, user_id="u1") + await asyncio.sleep(0) + assert cc.user_to_process["u1"] == "procB" + assert "procA" not in cc.connections + + def test_remove_connection(self): + cc = ConnectionConfig() + cc.connections["proc1"] = MagicMock() + cc.user_to_process["u1"] = "proc1" + cc.remove_connection("proc1") + assert "proc1" not in cc.connections + assert "u1" not in cc.user_to_process + + @pytest.mark.asyncio + async def test_close_connection_found(self): + cc = ConnectionConfig() + ws = AsyncMock() + cc.connections["proc1"] = ws + await cc.close_connection("proc1") + ws.close.assert_awaited_once() + assert "proc1" not in cc.connections + + @pytest.mark.asyncio + async def test_close_connection_missing(self): + cc = ConnectionConfig() + await cc.close_connection("nope") # warns, no error + + @pytest.mark.asyncio + async def test_close_connection_error(self): + cc = ConnectionConfig() + ws = AsyncMock() + ws.close.side_effect = RuntimeError("boom") + cc.connections["proc1"] = ws + await cc.close_connection("proc1") + assert "proc1" not in cc.connections + + +class TestSendStatusUpdateAsync: + @pytest.mark.asyncio + async def test_no_user_id(self): + cc = ConnectionConfig() + await cc.send_status_update_async("m", user_id="") # early return + + @pytest.mark.asyncio + async def test_fallback_single_user(self): + cc = ConnectionConfig() + ws = AsyncMock() + cc.connections["proc1"] = ws + cc.user_to_process["real"] = "proc1" + await cc.send_status_update_async({"k": "v"}, user_id="wrong") + ws.send_text.assert_awaited_once() + + @pytest.mark.asyncio + async def test_no_process_multiple_users(self): + cc = ConnectionConfig() + cc.user_to_process["a"] = "p1" + cc.user_to_process["b"] = "p2" + await cc.send_status_update_async("m", user_id="wrong") # returns, no send + + @pytest.mark.asyncio + async def test_message_with_to_dict(self): + cc = ConnectionConfig() + ws = AsyncMock() + cc.connections["p1"] = ws + cc.user_to_process["u1"] = "p1" + msg = MagicMock() + msg.to_dict.return_value = {"x": 1} + await cc.send_status_update_async(msg, user_id="u1") + ws.send_text.assert_awaited_once() + + @pytest.mark.asyncio + async def test_message_to_dict_error_falls_back_to_str(self): + cc = ConnectionConfig() + ws = AsyncMock() + cc.connections["p1"] = ws + cc.user_to_process["u1"] = "p1" + msg = MagicMock() + msg.to_dict.side_effect = RuntimeError("bad") + await cc.send_status_update_async(msg, user_id="u1") + ws.send_text.assert_awaited_once() + + @pytest.mark.asyncio + async def test_send_error_removes_connection(self): + cc = ConnectionConfig() + ws = AsyncMock() + ws.send_text.side_effect = RuntimeError("boom") + cc.connections["p1"] = ws + cc.user_to_process["u1"] = "p1" + await cc.send_status_update_async("m", user_id="u1") + assert "p1" not in cc.connections + + @pytest.mark.asyncio + async def test_no_connection_for_process(self): + cc = ConnectionConfig() + cc.user_to_process["u1"] = "p1" # mapped but no connection object + await cc.send_status_update_async("m", user_id="u1") + assert "u1" not in cc.user_to_process + + +class TestSendStatusUpdateSync: + @pytest.mark.asyncio + async def test_sync_send_found(self): + cc = ConnectionConfig() + ws = AsyncMock() + cc.connections["p1"] = ws + cc.send_status_update("hello", "p1") + await asyncio.sleep(0) + ws.send_text.assert_awaited_once_with("hello") + + def test_sync_send_not_found(self): + cc = ConnectionConfig() + cc.send_status_update("hello", "missing") # warns, no error + + +# ----------------------------------------------------------------------- # +# TeamConfig +# ----------------------------------------------------------------------- # +class TestTeamConfig: + def test_set_and_get(self): + tc = TeamConfig() + team = MagicMock() + tc.set_current_team("u1", team) + assert tc.get_current_team("u1") is team + + def test_get_missing(self): + tc = TeamConfig() + assert tc.get_current_team("nope") is None + + +def test_module_singletons(): + assert isinstance(orchestration_config, OrchestrationConfig) + assert isinstance(connection_config, ConnectionConfig) + assert isinstance(team_config, TeamConfig) diff --git a/src/tests/backend/orchestration/test_orchestration_manager.py b/src/tests/backend/orchestration/test_orchestration_manager.py index 15245883e..ec3f7e45d 100644 --- a/src/tests/backend/orchestration/test_orchestration_manager.py +++ b/src/tests/backend/orchestration/test_orchestration_manager.py @@ -192,9 +192,11 @@ def _make_workflow_mock(run_return=None, executors=None): class MockTeamConfiguration: - def __init__(self, name="TestTeam", deployment_name="test_deployment"): + def __init__(self, name="TestTeam", deployment_name="test_deployment", team_id="test-team-id"): self.name = name self.deployment_name = deployment_name + self.team_id = team_id + self.id = team_id class MockDatabaseBase: @@ -347,7 +349,7 @@ async def test_given_valid_args_when_init_then_returns_workflow(self): mock_magentic_builder.assert_called_once() call_kwargs = mock_magentic_builder.call_args.kwargs assert call_kwargs["enable_plan_review"] is True - assert call_kwargs["intermediate_outputs"] is True + assert call_kwargs["output_from"] == "all" @pytest.mark.asyncio async def test_given_no_user_id_when_init_then_raises_value_error(self): @@ -437,6 +439,7 @@ async def test_given_existing_workflow_when_no_switch_then_returns_it(self): # Arrange mock_workflow = Mock() mock_workflow._terminated = False + mock_workflow._team_id = "test-team-id" orchestration_config.get_current_orchestration.return_value = mock_workflow # Act diff --git a/src/tests/backend/orchestration/test_user_interaction_agent.py b/src/tests/backend/orchestration/test_user_interaction_agent.py new file mode 100644 index 000000000..4154bef34 --- /dev/null +++ b/src/tests/backend/orchestration/test_user_interaction_agent.py @@ -0,0 +1,42 @@ +"""Unit tests for backend.orchestration.user_interaction_agent. + +Exercises create_user_interaction_agent by patching the (framework-provided) +Agent and MCPStreamableHTTPTool symbols plus MCPConfig.from_env, so the +factory's body runs without a live MCP server. +""" + +from contextlib import AsyncExitStack +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import backend.orchestration.user_interaction_agent as uia +from backend.orchestration.user_interaction_agent import create_user_interaction_agent + + +@pytest.mark.asyncio +async def test_create_user_interaction_agent(): + fake_cfg = SimpleNamespace(name="mcp-user", url="https://host/user_responses/mcp") + sentinel_agent = MagicMock(name="Agent") + tool_instance = AsyncMock() # supports async context manager protocol + + with patch.object(uia.MCPConfig, "from_env", return_value=fake_cfg) as from_env, \ + patch.object(uia, "MCPStreamableHTTPTool", return_value=tool_instance) as tool_cls, \ + patch.object(uia, "Agent", return_value=sentinel_agent) as agent_cls: + agent, stack = await create_user_interaction_agent( + chat_client=MagicMock(), user_id="user-123" + ) + + from_env.assert_called_once_with(domain="user_responses") + tool_cls.assert_called_once_with(name="mcp-user", url="https://host/user_responses/mcp") + assert agent is sentinel_agent + assert isinstance(stack, AsyncExitStack) + + # Agent constructed with the user_id embedded in the instructions. + _, kwargs = agent_cls.call_args + assert kwargs["name"] == "UserInteractionAgent" + assert "user-123" in kwargs["instructions"] + assert kwargs["tools"] == [tool_instance] + + await stack.aclose() diff --git a/src/tests/backend/services/test_team_service.py b/src/tests/backend/services/test_team_service.py index 7d452c314..0e38f8277 100644 --- a/src/tests/backend/services/test_team_service.py +++ b/src/tests/backend/services/test_team_service.py @@ -112,6 +112,7 @@ class MockTeamConfiguration: plan: str = "" starting_tasks: List[Any] = field(default_factory=list) user_id: str = "" + is_default: bool = False @dataclass class MockUserCurrentTeam: @@ -494,3 +495,333 @@ def test_extract_from_empty_agent(self): agent = {"name": "TestAgent"} models = service.extract_models_from_agent(agent) assert isinstance(models, set) + + def test_extract_from_agent_instructions(self): + service = TeamService() + agent = {"name": "A", "instructions": "Use gpt-5.1 for this"} + models = service.extract_models_from_agent(agent) + assert any("gpt-5" in m for m in models) + + def test_extract_from_agent_system_message_fallback(self): + service = TeamService() + agent = {"name": "A", "system_message": "Prefer claude-3-opus"} + models = service.extract_models_from_agent(agent) + assert any("claude-3" in m for m in models) + + +class TestExtractModelsFromText: + def test_matches_gpt5_variants(self): + service = TeamService() + models = service.extract_models_from_text("gpt-5.4-mini and gpt-5") + assert any("gpt-5" in m for m in models) + + def test_matches_other_families(self): + service = TeamService() + models = service.extract_models_from_text( + "claude-2 mistral-large gemini-pro text-embedding-ada davinci-3" + ) + assert len(models) >= 3 + + def test_no_matches_returns_empty(self): + service = TeamService() + assert service.extract_models_from_text("no model here") == set() + + +class TestExtractTeamLevelModels: + def test_top_level_fields(self): + service = TeamService() + models = service.extract_team_level_models( + {"default_model": "GPT-4", "model": "gpt-35", "llm_model": "o3"} + ) + assert "gpt-4" in models + assert "o3" in models + + def test_nested_settings_and_environment(self): + service = TeamService() + cfg = { + "settings": {"model": "gpt-4", "deployment_name": "dep1"}, + "environment": {"model": "gpt-5", "openai_deployment": "dep2"}, + } + models = service.extract_team_level_models(cfg) + assert "dep1" in models + assert "dep2" in models + + def test_empty_config(self): + service = TeamService() + assert service.extract_team_level_models({}) == set() + + +class TestValidateTeamModels: + @pytest.mark.asyncio + async def test_all_models_available(self): + service = TeamService() + foundry = MagicMock() + foundry.list_model_deployments = AsyncMock( + return_value=[{"name": "custom-model", "status": "Succeeded"}] + ) + with patch.object(team_service_module, "FoundryService", return_value=foundry): + cfg = {"agents": [{"deployment_name": "custom-model"}]} + is_valid, missing = await service.validate_team_models(cfg) + assert is_valid is True + assert missing == [] + + @pytest.mark.asyncio + async def test_missing_model_reported(self): + service = TeamService() + foundry = MagicMock() + foundry.list_model_deployments = AsyncMock(return_value=[]) + with patch.object(team_service_module, "FoundryService", return_value=foundry): + cfg = {"agents": [{"deployment_name": "not-deployed"}]} + is_valid, missing = await service.validate_team_models(cfg) + assert is_valid is False + assert "not-deployed" in missing + + @pytest.mark.asyncio + async def test_bypassed_models_skipped(self): + service = TeamService() + foundry = MagicMock() + foundry.list_model_deployments = AsyncMock(return_value=[]) + with patch.object(team_service_module, "FoundryService", return_value=foundry): + cfg = {"agents": [{"deployment_name": "gpt-5.4-mini"}]} + is_valid, missing = await service.validate_team_models(cfg) + assert is_valid is True + + @pytest.mark.asyncio + async def test_default_model_when_no_agents(self): + service = TeamService() + mock_config.AZURE_OPENAI_DEPLOYMENT_NAME = "gpt-5" + foundry = MagicMock() + foundry.list_model_deployments = AsyncMock(return_value=[]) + with patch.object(team_service_module, "FoundryService", return_value=foundry): + is_valid, missing = await service.validate_team_models({"agents": []}) + assert is_valid is True + + @pytest.mark.asyncio + async def test_exception_returns_valid(self): + service = TeamService() + with patch.object( + team_service_module, "FoundryService", side_effect=Exception("boom") + ): + is_valid, missing = await service.validate_team_models({"agents": []}) + assert is_valid is True + assert missing == [] + + +class TestDeploymentStatusSummary: + @pytest.mark.asyncio + async def test_summary_categorizes_deployments(self): + service = TeamService() + foundry = MagicMock() + foundry.list_model_deployments = AsyncMock( + return_value=[ + {"name": "ok", "status": "Succeeded"}, + {"name": "bad", "status": "Failed"}, + {"name": "wait", "status": "Running"}, + ] + ) + with patch.object(team_service_module, "FoundryService", return_value=foundry): + summary = await service.get_deployment_status_summary() + assert summary["total_deployments"] == 3 + assert "ok" in summary["successful_deployments"] + assert "bad" in summary["failed_deployments"] + assert "wait" in summary["pending_deployments"] + + @pytest.mark.asyncio + async def test_summary_error(self): + service = TeamService() + with patch.object( + team_service_module, "FoundryService", side_effect=Exception("boom") + ): + summary = await service.get_deployment_status_summary() + assert "error" in summary + + +class TestSearchIndexExtraction: + def test_extract_index_names_from_rag_agents(self): + service = TeamService() + cfg = { + "agents": [ + {"type": "RAG", "index_name": "idx-1"}, + {"type": "ai", "index_name": "ignored"}, + {"type": "rag", "index_name": "idx-1"}, + ] + } + names = service.extract_index_names(cfg) + assert names == ["idx-1"] + + def test_extract_index_names_empty(self): + service = TeamService() + assert service.extract_index_names({"agents": []}) == [] + + def test_has_rag_or_search_agents_true(self): + service = TeamService() + assert service.has_rag_or_search_agents({"agents": [{"type": "rag"}]}) is True + + def test_has_rag_or_search_agents_false(self): + service = TeamService() + assert service.has_rag_or_search_agents({"agents": [{"type": "ai"}]}) is False + + +class TestValidateTeamSearchIndexes: + @pytest.mark.asyncio + async def test_no_indexes_no_rag_skips(self): + service = TeamService() + is_valid, errors = await service.validate_team_search_indexes( + {"agents": [{"type": "ai"}]} + ) + assert is_valid is True + assert errors == [] + + @pytest.mark.asyncio + async def test_rag_but_no_endpoint(self): + service = TeamService() + service.search_endpoint = "" + cfg = {"agents": [{"type": "rag", "index_name": "idx-1"}]} + is_valid, errors = await service.validate_team_search_indexes(cfg) + assert is_valid is False + assert errors + + @pytest.mark.asyncio + async def test_rag_without_specific_index(self): + service = TeamService() + service.search_endpoint = "https://s.search.windows.net" + cfg = {"agents": [{"type": "rag"}]} + is_valid, errors = await service.validate_team_search_indexes(cfg) + assert is_valid is True + assert errors == [] + + @pytest.mark.asyncio + async def test_valid_indexes(self): + service = TeamService() + service.search_endpoint = "https://s.search.windows.net" + cfg = {"agents": [{"type": "rag", "index_name": "idx-1"}]} + client = MagicMock() + client.get_index = MagicMock(return_value=MagicMock()) + with patch.object(team_service_module, "SearchIndexClient", return_value=client): + is_valid, errors = await service.validate_team_search_indexes(cfg) + assert is_valid is True + + @pytest.mark.asyncio + async def test_invalid_index(self): + service = TeamService() + service.search_endpoint = "https://s.search.windows.net" + cfg = {"agents": [{"type": "rag", "index_name": "missing"}]} + client = MagicMock() + client.get_index = MagicMock( + side_effect=team_service_module.ResourceNotFoundError("nope") + ) + with patch.object(team_service_module, "SearchIndexClient", return_value=client): + is_valid, errors = await service.validate_team_search_indexes(cfg) + assert is_valid is False + assert errors + + @pytest.mark.asyncio + async def test_validation_exception(self): + service = TeamService() + service.search_endpoint = "https://s.search.windows.net" + cfg = {"agents": [{"type": "rag", "index_name": "idx"}]} + with patch.object( + team_service_module, + "SearchIndexClient", + side_effect=Exception("boom"), + ): + is_valid, errors = await service.validate_team_search_indexes(cfg) + assert is_valid is False + + +class TestValidateSingleIndex: + @pytest.mark.asyncio + async def test_found(self): + service = TeamService() + client = MagicMock() + client.get_index = MagicMock(return_value=MagicMock()) + with patch.object(team_service_module, "SearchIndexClient", return_value=client): + is_valid, err = await service.validate_single_index("idx") + assert is_valid is True + assert err == "" + + @pytest.mark.asyncio + async def test_index_none(self): + service = TeamService() + client = MagicMock() + client.get_index = MagicMock(return_value=None) + with patch.object(team_service_module, "SearchIndexClient", return_value=client): + is_valid, err = await service.validate_single_index("idx") + assert is_valid is False + + @pytest.mark.asyncio + async def test_not_found(self): + service = TeamService() + client = MagicMock() + client.get_index = MagicMock( + side_effect=team_service_module.ResourceNotFoundError("x") + ) + with patch.object(team_service_module, "SearchIndexClient", return_value=client): + is_valid, err = await service.validate_single_index("idx") + assert is_valid is False + assert "does not exist" in err + + @pytest.mark.asyncio + async def test_auth_error(self): + service = TeamService() + client = MagicMock() + client.get_index = MagicMock( + side_effect=team_service_module.ClientAuthenticationError("x") + ) + with patch.object(team_service_module, "SearchIndexClient", return_value=client): + is_valid, err = await service.validate_single_index("idx") + assert is_valid is False + assert "Authentication failed" in err + + @pytest.mark.asyncio + async def test_http_error(self): + service = TeamService() + client = MagicMock() + client.get_index = MagicMock( + side_effect=team_service_module.HttpResponseError("x") + ) + with patch.object(team_service_module, "SearchIndexClient", return_value=client): + is_valid, err = await service.validate_single_index("idx") + assert is_valid is False + + @pytest.mark.asyncio + async def test_generic_error(self): + service = TeamService() + client = MagicMock() + client.get_index = MagicMock(side_effect=Exception("weird")) + with patch.object(team_service_module, "SearchIndexClient", return_value=client): + is_valid, err = await service.validate_single_index("idx") + assert is_valid is False + + +class TestSearchIndexSummary: + @pytest.mark.asyncio + async def test_no_endpoint(self): + service = TeamService() + service.search_endpoint = "" + summary = await service.get_search_index_summary() + assert "error" in summary + + @pytest.mark.asyncio + async def test_success(self): + service = TeamService() + service.search_endpoint = "https://s.search.windows.net" + idx = MagicMock() + idx.name = "idx-1" + client = MagicMock() + client.list_indexes = MagicMock(return_value=[idx]) + with patch.object(team_service_module, "SearchIndexClient", return_value=client): + summary = await service.get_search_index_summary() + assert summary["total_indexes"] == 1 + assert "idx-1" in summary["available_indexes"] + + @pytest.mark.asyncio + async def test_error(self): + service = TeamService() + service.search_endpoint = "https://s.search.windows.net" + with patch.object( + team_service_module, "SearchIndexClient", side_effect=Exception("boom") + ): + summary = await service.get_search_index_summary() + assert "error" in summary + diff --git a/src/tests/backend/tools/test_clarification_tool.py b/src/tests/backend/tools/test_clarification_tool.py new file mode 100644 index 000000000..2cccf5f4f --- /dev/null +++ b/src/tests/backend/tools/test_clarification_tool.py @@ -0,0 +1,50 @@ +"""Unit tests for backend.tools.clarification_tool. + +Covers the shared answer store (store_answer / pop_answer) and the +approval-gated request_user_clarification tool body (thread-local lookup, +fallback, and no-answer error path). +""" + +import threading + +import backend.tools.clarification_tool as ct +from backend.tools.clarification_tool import ( + pop_answer, + request_user_clarification, + store_answer, +) + + +def _clear_store(): + ct._pending_answers.clear() + + +class TestAnswerStore: + def test_store_and_pop(self): + _clear_store() + store_answer("req1", "the answer") + assert pop_answer("req1") == "the answer" + # popped -> gone + assert pop_answer("req1") == "" + + def test_pop_missing_returns_empty(self): + _clear_store() + assert pop_answer("nope") == "" + + +class TestRequestUserClarification: + def test_thread_local_answer(self): + _clear_store() + thread_key = f"_clarification_{threading.current_thread().ident}" + ct._pending_answers[thread_key] = "threaded answer" + assert request_user_clarification("q?") == "threaded answer" + + def test_fallback_to_any_remaining(self): + _clear_store() + ct._pending_answers["some_request_id"] = "fallback answer" + assert request_user_clarification("q?") == "fallback answer" + + def test_no_answer_returns_error(self): + _clear_store() + result = request_user_clarification("q?") + assert result == "Error: No answer was provided by the user."