diff --git a/.gitignore b/.gitignore index 1e2be1a5..f3431736 100644 --- a/.gitignore +++ b/.gitignore @@ -93,8 +93,6 @@ package-lock.json.bak out/ coverage/ -# We should have at some point .vscode, but for not ignore since we don't have standard -.vscode .claude/settings.local.json .idea/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..44d22515 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "orta.vscode-jest" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..55cf2b93 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "codeReview.agents": [ + "architecture-reviewer", + "code-reviewer", + "test-coverage-reviewer" + ], + "jest.jestCommandLine": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config=tests/jest.config.cjs", + "jest.rootPath": "." +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 05aca207..bfbf86bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,18 +128,25 @@ packages/agents-a365-/ 3. **Builder Pattern**: `ObservabilityBuilder` and `BaggageBuilder` provide fluent APIs 4. **Strategy Pattern**: `McpToolServerConfigurationService` switches between manifest (dev) and gateway (prod) based on `NODE_ENV` 5. **Extension Methods**: `notifications` package extends `AgentApplication` via TypeScript declaration merging +6. **Configuration Provider Pattern**: Hierarchical configuration with function-based overrides for multi-tenant support. Each package has its own configuration class (`RuntimeConfiguration`, `ToolingConfiguration`, `ObservabilityConfiguration`) with default singleton providers (`defaultRuntimeConfigurationProvider`, etc.) ## Core Package Functionality ### Runtime (`@microsoft/agents-a365-runtime`) Foundation package with no SDK dependencies. Provides: +- **`RuntimeConfiguration`**: Base configuration class with `clusterCategory`, `isDevelopmentEnvironment`, `isNodeEnvDevelopment` +- **`defaultRuntimeConfigurationProvider`**: Singleton configuration provider for runtime settings - **`Utility`**: Token decoding (`GetAppIdFromToken`), agent identity resolution (`ResolveAgentIdentity`), User-Agent generation (`GetUserAgentHeader`) - **`AgenticAuthenticationService`**: Token exchange for MCP platform auth - **`PowerPlatformApiDiscovery`**: Endpoint discovery for different clouds (prod, gov, dod, mooncake, etc.) -- **Environment utilities**: `getClusterCategory()`, `isDevelopmentEnvironment()`, `getObservabilityAuthenticationScope()`, `getMcpPlatformAuthenticationScope()` +- **Environment utilities** _(deprecated)_: `getClusterCategory()`, `isDevelopmentEnvironment()`, etc. - use `RuntimeConfiguration` instead ### Observability (`@microsoft/agents-a365-observability`) OpenTelemetry-based distributed tracing: +- **`ObservabilityConfiguration`**: Configuration with `observabilityAuthenticationScopes`, `isObservabilityExporterEnabled`, `observabilityLogLevel`, etc. +- **`PerRequestSpanProcessorConfiguration`**: Extends `ObservabilityConfiguration` with per-request processor settings (`isPerRequestExportEnabled`, `perRequestMaxTraces`, `perRequestMaxSpansPerTrace`, `perRequestMaxConcurrentExports`). Separated from `ObservabilityConfiguration` because these settings are only relevant when using `PerRequestSpanProcessor`. +- **`defaultObservabilityConfigurationProvider`**: Singleton configuration provider for observability settings +- **`defaultPerRequestSpanProcessorConfigurationProvider`**: Singleton configuration provider for per-request span processor settings - **`ObservabilityManager`**: Main entry point (singleton) - **`ObservabilityBuilder`**: Fluent configuration API - **Scope classes**: @@ -160,6 +167,8 @@ BaggageBuilder.build().run() ### Tooling (`@microsoft/agents-a365-tooling`) MCP tool server discovery and configuration: +- **`ToolingConfiguration`**: Configuration with `mcpPlatformEndpoint`, `mcpPlatformAuthenticationScope`, `useToolingManifest` +- **`defaultToolingConfigurationProvider`**: Singleton configuration provider for tooling settings - **`McpToolServerConfigurationService`**: Discover/configure MCP tool servers - Dev mode (`NODE_ENV=Development`): Loads from `ToolingManifest.json` - Prod mode (default): Discovers from Agent365 gateway endpoint @@ -199,9 +208,17 @@ The keyword "Kairo" is legacy and should not appear in any code. Flag and remove |----------|---------|--------| | `NODE_ENV` | Dev vs prod mode | `Development`, `production` (default) | | `CLUSTER_CATEGORY` | Environment classification | `local`, `dev`, `test`, `preprod`, `prod`, `gov`, `high`, `dod`, `mooncake`, `ex`, `rx` | -| `A365_OBSERVABILITY_SCOPES_OVERRIDE` | Override observability auth scopes | Space-separated scope strings | -| `MCP_PLATFORM_AUTHENTICATION_SCOPE` | MCP platform auth scope | Scope string | | `MCP_PLATFORM_ENDPOINT` | MCP platform base URL | URL string | +| `MCP_PLATFORM_AUTHENTICATION_SCOPE` | MCP platform auth scope | Scope string | +| `A365_OBSERVABILITY_SCOPES_OVERRIDE` | Override observability auth scopes | Space-separated scope strings | +| `ENABLE_A365_OBSERVABILITY_EXPORTER` | Enable Agent365 exporter | `true`, `false` (default) | +| `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` | Enable per-request export mode | `true`, `false` (default) | +| `A365_OBSERVABILITY_USE_CUSTOM_DOMAIN` | Use custom domain for export | `true`, `false` (default) | +| `A365_OBSERVABILITY_DOMAIN_OVERRIDE` | Custom domain URL override | URL string | +| `A365_OBSERVABILITY_LOG_LEVEL` | Internal logging level | `none` (default), `error`, `warn`, `info`, `debug` | +| `A365_PER_REQUEST_MAX_TRACES` | Max buffered traces per request (`PerRequestSpanProcessorConfiguration`) | Number (default: 1000) | +| `A365_PER_REQUEST_MAX_SPANS_PER_TRACE` | Max spans per trace (`PerRequestSpanProcessorConfiguration`) | Number (default: 5000) | +| `A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS` | Max concurrent exports (`PerRequestSpanProcessorConfiguration`) | Number (default: 20) | ## Testing diff --git a/docs/design.md b/docs/design.md index 5985fb6b..1bdc3355 100644 --- a/docs/design.md +++ b/docs/design.md @@ -67,14 +67,14 @@ Core utilities shared across the SDK. | `AgenticAuthenticationService` | Token exchange for MCP platform authentication | | `PowerPlatformApiDiscovery` | Endpoint discovery for different cloud environments | -**Environment Utilities:** +**Configuration:** -| Function | Purpose | +| Property | Purpose | |----------|---------| -| `getObservabilityAuthenticationScope()` | Get auth scopes for observability service | -| `getClusterCategory()` | Get environment classification (prod, dev, local) | -| `isDevelopmentEnvironment()` | Check if running in development mode | -| `getMcpPlatformAuthenticationScope()` | Get MCP platform authentication scope | +| `clusterCategory` | Environment classification (prod, dev, local) | +| `isDevelopmentEnvironment` | Check if running in development mode | +| `mcpPlatformAuthenticationScope` | MCP platform authentication scope | +| `observabilityAuthenticationScopes` | Auth scopes for observability service | **Usage Example:** @@ -82,9 +82,14 @@ Core utilities shared across the SDK. import { Utility, PowerPlatformApiDiscovery, - getClusterCategory, + defaultRuntimeConfigurationProvider, } from '@microsoft/agents-a365-runtime'; +// Access configuration via the default provider +const config = defaultRuntimeConfigurationProvider.getConfiguration(); +console.log(`Cluster: ${config.clusterCategory}`); +console.log(`Is dev: ${config.isDevelopmentEnvironment}`); + // Decode agent identity from JWT token const appId = Utility.GetAppIdFromToken(jwtToken); @@ -92,7 +97,7 @@ const appId = Utility.GetAppIdFromToken(jwtToken); const agentId = Utility.ResolveAgentIdentity(turnContext, authToken); // Discover Power Platform endpoints -const discovery = new PowerPlatformApiDiscovery('prod'); +const discovery = new PowerPlatformApiDiscovery(config.clusterCategory); const endpoint = discovery.getTenantIslandClusterEndpoint(tenantId); // Generate User-Agent header @@ -340,7 +345,94 @@ async listToolServers(agenticAppId: string, authToken: string): Promise getTenantCluster(currentTenantId), +}; +const tenantProvider = new DefaultConfigurationProvider( + () => new RuntimeConfiguration(options) +); +const tenantConfig = tenantProvider.getConfiguration(); +``` + +**Configuration Resolution Order:** + +Each configuration property follows a consistent resolution chain. The first non-undefined value wins: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Configuration Resolution Order │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────┐ │ +│ │ Override Function │ ← Called on EVERY property access │ +│ │ (if provided) │ Enables per-request/per-tenant values │ +│ └──────────┬───────────┘ │ +│ │ │ +│ ▼ returns undefined? │ +│ │ │ +│ ┌──────────────────────┐ │ +│ │ Environment Variable│ ← Process-level configuration │ +│ │ (if set and valid) │ Standard 12-factor app approach │ +│ └──────────┬───────────┘ │ +│ │ │ +│ ▼ not set or invalid? │ +│ │ │ +│ ┌──────────────────────┐ │ +│ │ Default Value │ ← Built-in production defaults │ +│ │ (always defined) │ Safe fallback for all properties │ +│ └──────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +**Example Resolution:** + +```typescript +// Configuration class getter implementation pattern: +get clusterCategory(): ClusterCategory { + // 1. Check override function + const override = this.overrides.clusterCategory?.(); + if (override !== undefined) return override; // ← Override wins + + // 2. Check environment variable + const envValue = process.env.CLUSTER_CATEGORY; + if (isValidClusterCategory(envValue)) return envValue; // ← Env var wins + + // 3. Return default + return ClusterCategory.prod; // ← Default fallback +} +``` + +**Key Characteristics:** + +| Aspect | Behavior | +|--------|----------| +| **Dynamic resolution** | Override functions called on each access, not cached | +| **Undefined vs false** | `undefined` falls through; explicit `false` is used | +| **Validation** | Invalid env var values fall through to defaults | +| **Thread safety** | Safe for concurrent access (no shared mutable state) | + +**Inheritance Hierarchy:** +- `RuntimeConfiguration` → `ToolingConfiguration`, `ObservabilityConfiguration` +- Each child package extends the base with additional settings + +### 6. Extension Methods Pattern The notifications package uses TypeScript declaration merging to extend `AgentApplication`: diff --git a/docs/prd-configuration-provider.md b/docs/prd-configuration-provider.md new file mode 100644 index 00000000..6bc24ab3 --- /dev/null +++ b/docs/prd-configuration-provider.md @@ -0,0 +1,1181 @@ +# PRD: Configuration Provider for Agent365 SDK + +## Document Information + +| Field | Value | +|-------|-------| +| Status | Draft | +| Author | Agent365 Team | +| Created | 2026-02-02 | +| Last Updated | 2026-02-04 | + +--- + +## 1. Problem Statement + +### Current State + +The Agent365 SDK currently relies on environment variables for all configuration settings. Configuration is read directly from `process.env` wherever needed throughout the codebase. + +### Limitations + +1. **No Multi-Tenant Support**: Settings are global and cannot vary per tenant/user +2. **No Programmatic Override**: Consumers cannot override settings without modifying environment variables +3. **Scattered Access**: `process.env` is accessed in 15+ locations across 4 packages +4. **Testing Friction**: Tests must manipulate `process.env` directly, risking pollution +5. **No Validation**: Settings are parsed at point-of-use with no centralized validation +6. **No Dynamic Resolution**: Settings cannot vary based on request context (e.g., async local storage) + +### User Stories + +1. **As a multi-tenant application developer**, I need different MCP endpoints per tenant so that each tenant can use their own infrastructure. + +2. **As an SDK consumer**, I want to programmatically configure the SDK without relying on environment variables so that I can integrate with my existing configuration system. + +3. **As a developer**, I want sensible defaults from environment variables so that simple deployments "just work" without code changes. + +--- + +## 2. Configuration Inventory + +### 2.1 Complete Settings Catalog + +#### Core Runtime Settings + +| Setting | Env Variable | Default | Type | Used In | +|---------|--------------|---------|------|---------| +| `clusterCategory` | `CLUSTER_CATEGORY` | `'prod'` | `ClusterCategory` | RuntimeConfiguration | +| `isDevelopmentEnvironment` | _(derived)_ | `false` | `boolean` | RuntimeConfiguration | +| `isNodeEnvDevelopment` | `NODE_ENV` | `false` (true if NODE_ENV='development') | `boolean` | RuntimeConfiguration | + +**Derived Properties:** +- `isDevelopmentEnvironment`: Returns `true` when `clusterCategory` is `'local'` or `'dev'` +- `isNodeEnvDevelopment`: Returns `true` when `NODE_ENV` equals `'development'` (case-insensitive). Used by `ToolingConfiguration.useToolingManifest`. + +#### Tooling Settings + +| Setting | Env Variable | Default | Type | Used In | +|---------|--------------|---------|------|---------| +| `mcpPlatformEndpoint` | `MCP_PLATFORM_ENDPOINT` | `'https://agent365.svc.cloud.microsoft'` | `string` | ToolingConfiguration | +| `mcpPlatformAuthenticationScope` | `MCP_PLATFORM_AUTHENTICATION_SCOPE` | `'ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default'` | `string` | ToolingConfiguration | +| `useToolingManifest` | `NODE_ENV` | `false` (true if NODE_ENV='development') | `boolean` | ToolingConfiguration | + +#### Observability Settings + +| Setting | Env Variable | Default | Type | Used In | +|---------|--------------|---------|------|---------| +| `observabilityAuthenticationScopes` | `A365_OBSERVABILITY_SCOPES_OVERRIDE` | `['https://api.powerplatform.com/.default']` | `string[]` | ObservabilityConfiguration | +| `isObservabilityExporterEnabled` | `ENABLE_A365_OBSERVABILITY_EXPORTER` | `false` | `boolean` | ObservabilityConfiguration | +| `useCustomDomainForObservability` | `A365_OBSERVABILITY_USE_CUSTOM_DOMAIN` | `false` | `boolean` | ObservabilityConfiguration | +| `observabilityDomainOverride` | `A365_OBSERVABILITY_DOMAIN_OVERRIDE` | `null` | `string \| null` | ObservabilityConfiguration | +| `observabilityLogLevel` | `A365_OBSERVABILITY_LOG_LEVEL` | `'none'` | `string` | ObservabilityConfiguration | + +#### Per-Request Processor Settings (Advanced) + +| Setting | Env Variable | Default | Type | Used In | +|---------|--------------|---------|------|---------| +| `isPerRequestExportEnabled` | `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` | `false` | `boolean` | PerRequestSpanProcessorConfiguration | +| `perRequestMaxTraces` | `A365_PER_REQUEST_MAX_TRACES` | `1000` | `number` | PerRequestSpanProcessorConfiguration | +| `perRequestMaxSpansPerTrace` | `A365_PER_REQUEST_MAX_SPANS_PER_TRACE` | `5000` | `number` | PerRequestSpanProcessorConfiguration | +| `perRequestMaxConcurrentExports` | `A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS` | `20` | `number` | PerRequestSpanProcessorConfiguration | + +### 2.2 Hardcoded Constants (Not Configurable) + +| Constant | Value | Location | Notes | +|----------|-------|----------|-------| +| HTTP Timeout | 30000ms | Agent365Exporter.ts | Export timeout | +| Max Retries | 3 | Agent365Exporter.ts | Export retries | +| Chat History Timeout | 10000ms | McpToolServerConfigurationService.ts | API timeout | +| Flush Grace Period | 250ms | PerRequestSpanProcessor.ts | Per-request | +| Max Trace Age | 30000ms | PerRequestSpanProcessor.ts | Per-request | +| Batch Queue Size | 2048 | Agent365ExporterOptions.ts | Batch processor | +| Batch Delay | 5000ms | Agent365ExporterOptions.ts | Batch processor | +| Max Batch Size | 512 | Agent365ExporterOptions.ts | Batch processor | + +### 2.3 Settings Access Locations + +``` +packages/agents-a365-runtime/src/ +├── environment-utils.ts # 3 env vars (lines 30, 45, 70) +└── power-platform-api-discovery.ts # Uses getClusterCategory() + +packages/agents-a365-tooling/src/ +├── Utility.ts # 1 env var (line 156) +└── McpToolServerConfigurationService.ts # 1 env var (line 287) + +packages/agents-a365-observability/src/ +├── utils/logging.ts # 1 env var (line 70) +├── tracing/exporter/utils.ts # 4 env vars (lines 116, 129, 142, 168) +├── tracing/PerRequestSpanProcessor.ts # Uses PerRequestSpanProcessorConfiguration +└── configuration/PerRequestSpanProcessorConfiguration.ts # 4 env vars (isPerRequestExportEnabled + perRequest*) +``` + +--- + +## 3. Proposed Solution + +### 3.1 Design Overview + +Configuration is distributed across packages with an **inheritance-based** design. Each package defines only its own settings, and child configurations inherit from parent configurations. + +**Key Design Choice**: Overrides are **functions** that are called on each property access. This enables: +- **Dynamic resolution**: Functions can read from async context (e.g., OpenTelemetry baggage) per-request +- **Multi-tenant support**: Different values returned based on current request context +- **Simpler implementation**: No caching/lazy evaluation needed + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Runtime Package │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ IConfigurationProvider │ │ +│ │ (generic base interface) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ RuntimeConfiguration │ │ +│ │ - clusterCategory (calls override function or reads env) │ │ +│ │ - isDevelopmentEnvironment (derived) │ │ +│ │ - mcpPlatformAuthScope (used by AgenticAuthenticationSvc) │ │ +│ │ - observabilityAuthScopes (used by AgenticTokenCache) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ extends + ┌─────────────────────┴─────────────────────┐ + ▼ ▼ +┌─────────────────────────────┐ ┌──────────────────────────────────┐ +│ Tooling Package │ │ Observability Package │ +│ ┌───────────────────────┐ │ │ ┌────────────────────────────┐ │ +│ │ ToolingConfiguration │ │ │ │ ObservabilityConfig │ │ +│ │ extends Runtime │ │ │ │ extends Runtime │ │ +│ │ + mcpPlatformEndpoint │ │ │ │ + exporterEnabled │ │ +│ └───────────────────────┘ │ │ │ + perRequestExport │ │ +│ │ extends │ │ │ + customDomain │ │ +│ ▼ │ │ │ + domainOverride │ │ +│ ┌───────────────────────┐ │ │ │ + logLevel │ │ +│ │ OpenAIToolingConfig │ │ │ └────────────────────────────┘ │ +│ │ (empty - future use) │ │ │ │ extends │ +│ └───────────────────────┘ │ │ ▼ │ +│ │ │ ┌────────────────────────────┐ │ +│ │ │ │ PerRequestSpanProcessor │ │ +│ │ │ │ Configuration │ │ +│ │ │ │ + perRequestMaxTraces │ │ +│ │ │ │ + perRequestMaxSpans │ │ +│ │ │ │ + perRequestMaxExports │ │ +│ │ │ └────────────────────────────┘ │ +└─────────────────────────────┘ └──────────────────────────────────┘ +``` + +### 3.2 Core Components (Runtime Package) + +#### IConfigurationProvider Interface + +```typescript +// packages/agents-a365-runtime/src/configuration/IConfigurationProvider.ts + +/** + * Generic interface for providing configuration. + * Each package defines its own configuration type T. + */ +export interface IConfigurationProvider { + getConfiguration(): T; +} +``` + +#### RuntimeConfigurationOptions Type + +```typescript +// packages/agents-a365-runtime/src/configuration/RuntimeConfigurationOptions.ts + +import { ClusterCategory } from '../power-platform-api-discovery'; + +/** + * Runtime configuration options - all optional functions. + * Functions are called on each property access, enabling dynamic resolution. + * Unset values fall back to environment variables. + */ +export type RuntimeConfigurationOptions = { + clusterCategory?: () => ClusterCategory; + isNodeEnvDevelopment?: () => boolean; +}; +``` + +#### RuntimeConfiguration Class + +```typescript +// packages/agents-a365-runtime/src/configuration/RuntimeConfiguration.ts + +import { ClusterCategory } from '../power-platform-api-discovery'; +import { RuntimeConfigurationOptions } from './RuntimeConfigurationOptions'; + +/** + * Base configuration class for Agent365 SDK. + * Other packages extend this to add their own settings. + * + * Override functions are called on each property access, enabling dynamic + * resolution from async context (e.g., OpenTelemetry baggage) per-request. + */ +export class RuntimeConfiguration { + protected readonly overrides: RuntimeConfigurationOptions; + + constructor(overrides?: RuntimeConfigurationOptions) { + this.overrides = overrides ?? {}; + } + + get clusterCategory(): ClusterCategory { + return this.overrides.clusterCategory?.() + ?? (process.env.CLUSTER_CATEGORY?.toLowerCase() as ClusterCategory) + ?? 'prod'; + } + + get isDevelopmentEnvironment(): boolean { + return ['local', 'dev'].includes(this.clusterCategory); + } + + get isNodeEnvDevelopment(): boolean { + const override = this.overrides.isNodeEnvDevelopment?.(); + if (override !== undefined) return override; + + const nodeEnv = process.env.NODE_ENV ?? ''; + return nodeEnv.toLowerCase() === 'development'; + } +} +``` + +#### DefaultConfigurationProvider Class + +```typescript +// packages/agents-a365-runtime/src/configuration/DefaultConfigurationProvider.ts + +import { IConfigurationProvider } from './IConfigurationProvider'; +import { RuntimeConfiguration } from './RuntimeConfiguration'; + +/** + * Default provider that returns environment-based configuration. + * Use the static `instance` for shared access across the application. + */ +export class DefaultConfigurationProvider + implements IConfigurationProvider { + + private readonly _configuration: T; + + constructor(factory: () => T) { + this._configuration = factory(); + } + + getConfiguration(): T { + return this._configuration; + } +} + +/** + * Shared default provider for RuntimeConfiguration. + */ +export const defaultRuntimeConfigurationProvider = + new DefaultConfigurationProvider(() => new RuntimeConfiguration()); +``` + +### 3.3 Tooling Package Configuration + +#### ToolingConfigurationOptions Type + +```typescript +// packages/agents-a365-tooling/src/configuration/ToolingConfigurationOptions.ts + +import { RuntimeConfigurationOptions } from '@microsoft/agents-a365-runtime'; + +/** + * Tooling configuration options - extends runtime options. + * All overrides are functions called on each property access. + */ +export type ToolingConfigurationOptions = RuntimeConfigurationOptions & { + mcpPlatformEndpoint?: () => string; + /** + * Override for MCP platform authentication scope. + * Falls back to MCP_PLATFORM_AUTHENTICATION_SCOPE env var, then production default. + */ + mcpPlatformAuthenticationScope?: () => string; + /** + * Override for using local manifest vs gateway discovery. + * Falls back to NODE_ENV === 'development' check. + */ + useToolingManifest?: () => boolean; +}; +``` + +#### ToolingConfiguration Class + +```typescript +// packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts + +import { RuntimeConfiguration } from '@microsoft/agents-a365-runtime'; +import { ToolingConfigurationOptions } from './ToolingConfigurationOptions'; + +// Default MCP platform authentication scope +const PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE = 'ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default'; + +/** + * Configuration for tooling package. + * Inherits runtime settings and adds tooling-specific settings. + */ +export class ToolingConfiguration extends RuntimeConfiguration { + // Type-safe access to tooling overrides + protected get toolingOverrides(): ToolingConfigurationOptions { + return this.overrides as ToolingConfigurationOptions; + } + + constructor(overrides?: ToolingConfigurationOptions) { + super(overrides); + } + + // Inherited: clusterCategory, isDevelopmentEnvironment, isNodeEnvDevelopment + + get mcpPlatformEndpoint(): string { + return this.toolingOverrides.mcpPlatformEndpoint?.() + ?? process.env.MCP_PLATFORM_ENDPOINT + ?? 'https://agent365.svc.cloud.microsoft'; + } + + /** + * Gets the MCP platform authentication scope. + * Used by AgenticAuthenticationService for token exchange. + */ + get mcpPlatformAuthenticationScope(): string { + const override = this.toolingOverrides.mcpPlatformAuthenticationScope?.(); + if (override) return override; + + const envValue = process.env.MCP_PLATFORM_AUTHENTICATION_SCOPE; + if (envValue) return envValue; + + return PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE; + } + + /** + * Whether to use the local ToolingManifest.json file instead of gateway discovery. + * Returns true when NODE_ENV is set to 'development' (case-insensitive). + */ + get useToolingManifest(): boolean { + const override = this.toolingOverrides.useToolingManifest?.(); + if (override !== undefined) return override; + return this.isNodeEnvDevelopment; + } +} +``` + +#### Default Tooling Provider + +```typescript +// packages/agents-a365-tooling/src/configuration/index.ts + +import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { ToolingConfiguration } from './ToolingConfiguration'; + +export const defaultToolingConfigurationProvider = + new DefaultConfigurationProvider(() => new ToolingConfiguration()); +``` + +### 3.4 Observability Package Configuration + +#### ObservabilityConfigurationOptions Type + +```typescript +// packages/agents-a365-observability/src/configuration/ObservabilityConfigurationOptions.ts + +import { RuntimeConfigurationOptions } from '@microsoft/agents-a365-runtime'; + +/** + * Observability configuration options - extends runtime options. + * All overrides are functions called on each property access. + */ +export type ObservabilityConfigurationOptions = RuntimeConfigurationOptions & { + /** + * Override for observability authentication scopes. + * Falls back to A365_OBSERVABILITY_SCOPES_OVERRIDE env var, then production default. + */ + observabilityAuthenticationScopes?: () => string[]; + isObservabilityExporterEnabled?: () => boolean; + useCustomDomainForObservability?: () => boolean; + observabilityDomainOverride?: () => string | null; + observabilityLogLevel?: () => string; +}; +``` + +#### ObservabilityConfiguration Class + +```typescript +// packages/agents-a365-observability/src/configuration/ObservabilityConfiguration.ts + +import { RuntimeConfiguration } from '@microsoft/agents-a365-runtime'; +import { ObservabilityConfigurationOptions } from './ObservabilityConfigurationOptions'; + +// Default observability authentication scope +const PROD_OBSERVABILITY_SCOPE = 'https://api.powerplatform.com/.default'; + +/** + * Configuration for observability package. + * Inherits runtime settings and adds observability-specific settings. + */ +export class ObservabilityConfiguration extends RuntimeConfiguration { + protected get observabilityOverrides(): ObservabilityConfigurationOptions { + return this.overrides as ObservabilityConfigurationOptions; + } + + constructor(overrides?: ObservabilityConfigurationOptions) { + super(overrides); + } + + // Inherited: clusterCategory, isDevelopmentEnvironment, isNodeEnvDevelopment + + /** + * Gets the observability authentication scopes. + * Used by AgenticTokenCache for observability service authentication. + */ + get observabilityAuthenticationScopes(): readonly string[] { + const result = this.observabilityOverrides.observabilityAuthenticationScopes?.(); + if (result !== undefined) { + return result; + } + const override = process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE; + if (override?.trim()) { + return override.trim().split(/\s+/); + } + return [PROD_OBSERVABILITY_SCOPE]; + } + + get isObservabilityExporterEnabled(): boolean { + const result = this.observabilityOverrides.isObservabilityExporterEnabled?.(); + if (result !== undefined) { + return result; + } + const value = process.env.ENABLE_A365_OBSERVABILITY_EXPORTER?.toLowerCase() ?? ''; + return ['true', '1', 'yes', 'on'].includes(value); + } + + get useCustomDomainForObservability(): boolean { + const result = this.observabilityOverrides.useCustomDomainForObservability?.(); + if (result !== undefined) { + return result; + } + const value = process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN?.toLowerCase() ?? ''; + return ['true', '1', 'yes', 'on'].includes(value); + } + + get observabilityDomainOverride(): string | null { + const result = this.observabilityOverrides.observabilityDomainOverride?.(); + if (result !== undefined) { + return result; + } + const override = process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; + if (override?.trim()) { + return override.trim().replace(/\/+$/, ''); + } + return null; + } + + get observabilityLogLevel(): string { + return this.observabilityOverrides.observabilityLogLevel?.() + ?? process.env.A365_OBSERVABILITY_LOG_LEVEL + ?? 'none'; + } +} +``` + +### 3.5 Extension Package Example (OpenAI Tooling) + +> **Note**: This section shows an **illustrative example** of how extension packages could add their own settings. The actual `OpenAIToolingConfiguration` implementation currently has no custom properties beyond what it inherits from `ToolingConfiguration`. The `openAIModel` property shown below is hypothetical and demonstrates the extension pattern for when such settings are needed in the future. + +```typescript +// ILLUSTRATIVE EXAMPLE - showing how extension-specific settings could be added +// packages/agents-a365-tooling-extensions-openai/src/configuration/OpenAIToolingConfiguration.ts + +import { ToolingConfiguration, ToolingConfigurationOptions } from '@microsoft/agents-a365-tooling'; + +export type OpenAIToolingConfigurationOptions = ToolingConfigurationOptions & { + openAIModel?: () => string; +}; + +export class OpenAIToolingConfiguration extends ToolingConfiguration { + protected get openAIToolingOverrides(): OpenAIToolingConfigurationOptions { + return this.overrides as OpenAIToolingConfigurationOptions; + } + + constructor(overrides?: OpenAIToolingConfigurationOptions) { + super(overrides); + } + + // Inherited: clusterCategory, isDevelopmentEnvironment, isNodeEnvDevelopment, mcpPlatformEndpoint, mcpPlatformAuthenticationScope, useToolingManifest + + get openAIModel(): string { + return this.openAIToolingOverrides.openAIModel?.() + ?? process.env.OPENAI_MODEL + ?? 'gpt-4'; + } +} +``` + +### 3.6 Usage Examples + +```typescript +// Simple case - all from environment variables +const config = new ToolingConfiguration(); +console.log(config.clusterCategory); // From runtime (inherited) +console.log(config.mcpPlatformEndpoint); // From tooling + +// With static overrides - wrap values in arrow functions +const config = new OpenAIToolingConfiguration({ + clusterCategory: () => 'gov', // Runtime setting + mcpPlatformEndpoint: () => 'https://custom', // Tooling setting + openAIModel: () => 'gpt-4-turbo' // OpenAI setting +}); + +// Dynamic overrides - read from async context per-request +import { context } from '@opentelemetry/api'; + +const TENANT_CONFIG_KEY = context.createKey('tenant-config'); + +const config = new ToolingConfiguration({ + clusterCategory: () => { + const tenantConfig = context.active().getValue(TENANT_CONFIG_KEY); + return tenantConfig?.clusterCategory ?? 'prod'; + }, + mcpPlatformEndpoint: () => { + const tenantConfig = context.active().getValue(TENANT_CONFIG_KEY); + return tenantConfig?.mcpEndpoint ?? 'https://agent365.svc.cloud.microsoft'; + } +}); + +// Inject into services +class McpToolServerConfigurationService { + constructor( + private readonly configProvider: IConfigurationProvider = + defaultToolingConfigurationProvider + ) {} + + private get config(): ToolingConfiguration { + return this.configProvider.getConfiguration(); + } + + private isDevScenario(): boolean { + return this.config.isDevelopmentEnvironment; // From inherited RuntimeConfiguration + } + + private getMcpPlatformBaseUrl(): string { + return this.config.mcpPlatformEndpoint; // From ToolingConfiguration + } +} +``` + +### 3.7 File Structure Per Package + +``` +packages/agents-a365-runtime/src/ +├── configuration/ +│ ├── index.ts +│ ├── IConfigurationProvider.ts +│ ├── RuntimeConfigurationOptions.ts +│ ├── RuntimeConfiguration.ts +│ └── DefaultConfigurationProvider.ts +└── index.ts # Add: export * from './configuration' + +packages/agents-a365-tooling/src/ +├── configuration/ +│ ├── index.ts +│ ├── ToolingConfigurationOptions.ts +│ └── ToolingConfiguration.ts +└── index.ts # Add: export * from './configuration' + +packages/agents-a365-observability/src/ +├── configuration/ +│ ├── index.ts +│ ├── ObservabilityConfigurationOptions.ts +│ ├── ObservabilityConfiguration.ts +│ ├── PerRequestSpanProcessorConfigurationOptions.ts +│ └── PerRequestSpanProcessorConfiguration.ts +└── index.ts # Add: export * from './configuration' + +packages/agents-a365-tooling-extensions-openai/src/ +├── configuration/ +│ ├── index.ts +│ ├── OpenAIToolingConfigurationOptions.ts +│ └── OpenAIToolingConfiguration.ts +└── index.ts # Add: export * from './configuration' + +packages/agents-a365-tooling-extensions-claude/src/ +├── configuration/ +│ ├── index.ts +│ ├── ClaudeToolingConfigurationOptions.ts +│ └── ClaudeToolingConfiguration.ts +└── index.ts # Add: export * from './configuration' + +packages/agents-a365-tooling-extensions-langchain/src/ +├── configuration/ +│ ├── index.ts +│ ├── LangChainToolingConfigurationOptions.ts +│ └── LangChainToolingConfiguration.ts +└── index.ts # Add: export * from './configuration' + +packages/agents-a365-observability-extensions-openai/src/ +├── configuration/ +│ ├── index.ts +│ ├── OpenAIObservabilityConfigurationOptions.ts +│ └── OpenAIObservabilityConfiguration.ts +└── index.ts # Add: export * from './configuration' +``` + +> **Note**: All extension configuration classes (`ClaudeToolingConfiguration`, `LangChainToolingConfiguration`, `OpenAIToolingConfiguration`, `OpenAIObservabilityConfiguration`) currently have no custom properties beyond what they inherit. They exist as extension points for future package-specific settings and to enable type-safe dependency injection. + +### 3.8 Key Design Benefits + +| Aspect | Benefit | +|--------|---------| +| **Inheritance** | Child configs automatically have all parent settings | +| **Package ownership** | Each package defines only its own settings | +| **Single options object** | All overrides passed to constructor at once | +| **Type-safe** | Options types extend each other | +| **Dynamic resolution** | Functions called on each access - can read from async context | +| **Multi-tenant support** | Different values returned based on current request context | +| **Env var fallback** | Works out of the box without any overrides | +| **Testable** | Can override any setting for testing | +| **Simple implementation** | No caching/lazy evaluation complexity | + +--- + +## 4. Implementation Strategy + +### Phase 1: Establish Test Coverage Baseline (Pre-Implementation) + +**Goal**: Achieve comprehensive test coverage for all existing settings-related code BEFORE making changes. + +#### 4.1.1 Current Test Coverage Gaps + +| Area | Current Coverage | Target | Gap | +|------|-----------------|--------|-----| +| environment-utils.ts | 95% | 100% | Minor edge cases | +| McpToolServerConfigurationService (dev mode) | 85% | 100% | Error scenarios | +| McpToolServerConfigurationService (prod mode) | 10% | 80% | Gateway discovery | +| exporter/utils.ts | 80% | 100% | Boolean parsing variants | +| logging.ts | 0% | 100% | **CRITICAL - No tests exist** | +| PerRequestSpanProcessorConfiguration settings | 0% | 80% | Env var parsing | + +#### 4.1.2 New Test Files Required + +1. **`tests/observability/utils/logging.test.ts`** (NEW) + - Log level parsing with all valid values + - Pipe-separated combinations (`info|warn|error`) + - Invalid/malformed values + - Default value when not set + +2. **`tests/observability/tracing/exporter-utils.test.ts`** (NEW) + - `isAgent365ExporterEnabled()` with all boolean variants + - `isPerRequestExportEnabled()` with all boolean variants + - `useCustomDomainForObservability()` edge cases + - `getAgent365ObservabilityDomainOverride()` edge cases + - `resolveAgent365Endpoint()` for all cluster categories + +3. **`tests/observability/tracing/per-request-span-processor.test.ts`** (EXPAND) + - Environment variable parsing for all 3 settings + - Default values when not set + - Invalid numeric values + +4. **`tests/tooling/mcp-tool-server-configuration-service.test.ts`** (EXPAND) + - Production mode gateway discovery (mock HTTP) + - Invalid JSON manifest handling + - Network timeout scenarios + +#### 4.1.3 Test Patterns to Follow + +```typescript +// Standard environment variable test setup +const originalEnv = process.env; + +beforeEach(() => { + process.env = { ...originalEnv }; +}); + +afterEach(() => { + process.env = originalEnv; +}); + +// Parameterized boolean parsing tests +describe('isAgent365ExporterEnabled', () => { + it.each([ + { value: 'true', expected: true }, + { value: 'TRUE', expected: true }, + { value: '1', expected: true }, + { value: 'yes', expected: true }, + { value: 'YES', expected: true }, + { value: 'on', expected: true }, + { value: 'ON', expected: true }, + { value: 'false', expected: false }, + { value: '0', expected: false }, + { value: '', expected: false }, + { value: undefined, expected: false }, + ])('returns $expected when env var is "$value"', ({ value, expected }) => { + if (value !== undefined) { + process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = value; + } else { + delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; + } + expect(isAgent365ExporterEnabled()).toBe(expected); + }); +}); +``` + +### Phase 2: Implement Configuration Classes + +**Goal**: Implement the new configuration system in each package without breaking existing functionality. + +#### 4.2.1 Implementation Order (by dependency) + +**Step 1: Runtime Package (Foundation)** + +Create configuration module in `agents-a365-runtime`: +- `IConfigurationProvider.ts` - Generic provider interface +- `RuntimeConfigurationOptions.ts` - Options type (functions) +- `RuntimeConfiguration.ts` - Base configuration class +- `DefaultConfigurationProvider.ts` - Default provider implementation +- `index.ts` - Re-exports + +Write unit tests: +- `tests/runtime/configuration/RuntimeConfiguration.test.ts` +- `tests/runtime/configuration/DefaultConfigurationProvider.test.ts` + +**Step 2: Tooling Package** + +Create configuration module in `agents-a365-tooling`: +- `ToolingConfigurationOptions.ts` - Extends `RuntimeConfigurationOptions` +- `ToolingConfiguration.ts` - Extends `RuntimeConfiguration` +- `index.ts` - Re-exports + default provider + +Write unit tests: +- `tests/tooling/configuration/ToolingConfiguration.test.ts` + +**Step 3: Observability Package** + +Create configuration module in `agents-a365-observability`: +- `ObservabilityConfigurationOptions.ts` - Extends `RuntimeConfigurationOptions` +- `ObservabilityConfiguration.ts` - Extends `RuntimeConfiguration` +- `index.ts` - Re-exports + default provider + +Write unit tests: +- `tests/observability/configuration/ObservabilityConfiguration.test.ts` + +**Step 4: Extension Packages (as needed)** + +Create configuration in each extension package that extends parent: +- `agents-a365-tooling-extensions-openai`: `OpenAIToolingConfiguration extends ToolingConfiguration` +- `agents-a365-tooling-extensions-langchain`: `LangChainToolingConfiguration extends ToolingConfiguration` +- `agents-a365-tooling-extensions-claude`: `ClaudeToolingConfiguration extends ToolingConfiguration` + +#### 4.2.2 Build Verification + +After each step: +1. Run `pnpm build` to verify no compilation errors +2. Run `pnpm test` to verify new tests pass +3. Verify exports are correctly exposed + +### Phase 3: Uptake in Codebase + +**Goal**: Migrate existing code to use configuration provider while maintaining backward compatibility. + +#### 4.3.1 Migration Order + +``` +1. agents-a365-runtime + └── environment-utils.ts - Deprecate functions, delegate to RuntimeConfiguration + +2. agents-a365-tooling + ├── Utility.ts - Deprecate URL construction methods (use McpToolServerConfigurationService instead) + └── McpToolServerConfigurationService.ts - Accept IConfigurationProvider + +3. agents-a365-observability + ├── utils/logging.ts - Use ObservabilityConfiguration + ├── tracing/exporter/utils.ts - Use ObservabilityConfiguration + ├── tracing/PerRequestSpanProcessor.ts - Use PerRequestSpanProcessorConfiguration + └── ObservabilityBuilder.ts - Accept IConfigurationProvider + +4. Extension packages + ├── agents-a365-tooling-extensions-langchain - Accept IConfigurationProvider + ├── agents-a365-tooling-extensions-openai - Accept IConfigurationProvider + └── agents-a365-tooling-extensions-claude - Accept IConfigurationProvider +``` + +#### 4.3.2 Migration Pattern for Utility Functions + +**Before (environment-utils.ts):** +```typescript +export function getClusterCategory(): string { + const clusterCategory = process.env.CLUSTER_CATEGORY; + if (!clusterCategory) { + return 'prod'; + } + return clusterCategory.toLowerCase(); +} +``` + +**After (Backward Compatible - delegates to configuration):** +```typescript +import { defaultRuntimeConfigurationProvider } from './configuration'; + +/** + * @deprecated Use RuntimeConfiguration.clusterCategory instead + */ +export function getClusterCategory(): ClusterCategory { + return defaultRuntimeConfigurationProvider.getConfiguration().clusterCategory; +} +``` + +> **Note: Cross-Package Delegation Limitation** +> +> Some deprecated utility functions in `environment-utils.ts` cannot delegate to configuration providers because their configuration classes are in different packages, which would create circular dependencies: +> +> | Function | Configuration Property | Package | Can Delegate? | +> |----------|------------------------|---------|---------------| +> | `getClusterCategory()` | `RuntimeConfiguration.clusterCategory` | runtime | ✓ Yes | +> | `isDevelopmentEnvironment()` | `RuntimeConfiguration.isDevelopmentEnvironment` | runtime | ✓ Yes | +> | `getObservabilityAuthenticationScope()` | `ObservabilityConfiguration.observabilityAuthenticationScopes` | observability | ✗ No - circular dep | +> | `getMcpPlatformAuthenticationScope()` | `ToolingConfiguration.mcpPlatformAuthenticationScope` | tooling | ✗ No - circular dep | +> +> The functions that cannot delegate return **hardcoded production defaults** and include comments directing users to the appropriate configuration class for proper environment variable support. + +#### 4.3.3 Migration Pattern for Services + +**Before (McpToolServerConfigurationService):** +```typescript +class McpToolServerConfigurationService { + private isDevScenario(): boolean { + const environment = process.env.NODE_ENV || ''; + return environment.toLowerCase() === 'development'; + } + + private getMcpPlatformBaseUrl(): string { + return process.env.MCP_PLATFORM_ENDPOINT ?? 'https://agent365.svc.cloud.microsoft'; + } +} +``` + +**After:** +```typescript +import { defaultToolingConfigurationProvider } from './configuration'; + +class McpToolServerConfigurationService { + private isDevScenario(): boolean { + return defaultToolingConfigurationProvider.getConfiguration().useToolingManifest; + } +} +``` + +Note: `useToolingManifest` is a ToolingConfiguration property that checks `NODE_ENV === 'development'`. + +#### 4.3.4 Migration Pattern for Observability + +**Before (exporter/utils.ts):** +```typescript +export function isAgent365ExporterEnabled(): boolean { + const value = process.env.ENABLE_A365_OBSERVABILITY_EXPORTER?.toLowerCase() ?? ''; + return ['true', '1', 'yes', 'on'].includes(value); +} +``` + +**After:** +```typescript +import { defaultObservabilityConfigurationProvider } from '../configuration'; + +/** + * @deprecated Use ObservabilityConfiguration.isObservabilityExporterEnabled instead + */ +export function isAgent365ExporterEnabled(): boolean { + return defaultObservabilityConfigurationProvider.getConfiguration().isObservabilityExporterEnabled; +} +``` + +### Phase 4: Verify and Enforce + +**Goal**: Ensure all existing tests pass and prevent future direct `process.env` access. + +1. Run full test suite: `pnpm test` +2. Run integration tests: `pnpm test:integration` +3. Verify test coverage hasn't decreased: `pnpm test:coverage` +4. Verify no `process.env` reads remain outside configuration classes: + ```bash + # Should return 0 matches (excluding configuration/ directories) + grep -r "process\.env" packages/*/src --include="*.ts" | grep -v "/configuration/" + ``` +5. **Add ESLint rule** to `eslint.config.mjs` to prevent future violations (see Section 7.1) + +### Phase 5: Add Configuration Provider Tests + +**Goal**: Add tests specifically for the new configuration override capabilities. + +#### New Test Scenarios + +```typescript +// Runtime Configuration Tests +describe('RuntimeConfiguration', () => { + it('should use override function when provided', () => { + const config = new RuntimeConfiguration({ clusterCategory: () => 'gov' }); + expect(config.clusterCategory).toBe('gov'); + }); + + it('should fall back to env var when override not provided', () => { + process.env.CLUSTER_CATEGORY = 'dev'; + const config = new RuntimeConfiguration({}); + expect(config.clusterCategory).toBe('dev'); + }); + + it('should fall back to default when neither override nor env var', () => { + delete process.env.CLUSTER_CATEGORY; + const config = new RuntimeConfiguration({}); + expect(config.clusterCategory).toBe('prod'); + }); + + it('should call override function on each access (dynamic resolution)', () => { + let callCount = 0; + const config = new RuntimeConfiguration({ + clusterCategory: () => { + callCount++; + return 'gov'; + } + }); + config.clusterCategory; + config.clusterCategory; + expect(callCount).toBe(2); // Called twice, not cached + }); + + it('should support dynamic values from async context', () => { + let currentTenant = 'tenant-a'; + const tenantConfigs = { + 'tenant-a': 'prod', + 'tenant-b': 'gov' + }; + const config = new RuntimeConfiguration({ + clusterCategory: () => tenantConfigs[currentTenant] as ClusterCategory + }); + + expect(config.clusterCategory).toBe('prod'); + currentTenant = 'tenant-b'; + expect(config.clusterCategory).toBe('gov'); // Dynamic! + }); + + it('should derive isDevelopmentEnvironment from clusterCategory', () => { + expect(new RuntimeConfiguration({ clusterCategory: () => 'local' }).isDevelopmentEnvironment).toBe(true); + expect(new RuntimeConfiguration({ clusterCategory: () => 'dev' }).isDevelopmentEnvironment).toBe(true); + expect(new RuntimeConfiguration({ clusterCategory: () => 'prod' }).isDevelopmentEnvironment).toBe(false); + }); +}); + +// Tooling Configuration Tests (Inheritance) +describe('ToolingConfiguration', () => { + it('should inherit runtime settings', () => { + const config = new ToolingConfiguration({ clusterCategory: () => 'gov' }); + expect(config.clusterCategory).toBe('gov'); + expect(config.isDevelopmentEnvironment).toBe(false); + }); + + it('should have tooling-specific settings', () => { + const config = new ToolingConfiguration({ mcpPlatformEndpoint: () => 'https://custom.endpoint' }); + expect(config.mcpPlatformEndpoint).toBe('https://custom.endpoint'); + }); + + it('should allow overriding both runtime and tooling settings', () => { + const config = new ToolingConfiguration({ + clusterCategory: () => 'dev', + mcpPlatformEndpoint: () => 'https://dev.endpoint' + }); + expect(config.clusterCategory).toBe('dev'); + expect(config.isDevelopmentEnvironment).toBe(true); + expect(config.mcpPlatformEndpoint).toBe('https://dev.endpoint'); + }); +}); + +// Observability Configuration Tests (Inheritance) +describe('ObservabilityConfiguration', () => { + it('should inherit runtime settings', () => { + const config = new ObservabilityConfiguration({ clusterCategory: () => 'gov' }); + expect(config.clusterCategory).toBe('gov'); + }); + + it('should have observability-specific settings', () => { + const config = new ObservabilityConfiguration({ isObservabilityExporterEnabled: () => true }); + expect(config.isObservabilityExporterEnabled).toBe(true); + }); +}); +``` + +--- + +## 5. Test Coverage Requirements + +### 5.1 Phase 1 Completion Criteria + +Before implementing the configuration provider: + +| Test File | Required Tests | Status | +|-----------|---------------|--------| +| `logging.test.ts` | 12+ test cases | ⬜ Not started | +| `exporter-utils.test.ts` | 20+ test cases | ⬜ Not started | +| `per-request-span-processor.test.ts` | 8+ test cases | ⬜ Not started | +| `mcp-tool-server-configuration-service.test.ts` | 5+ additional cases | ⬜ Not started | +| `environment-utils.test.ts` | 3+ edge cases | ⬜ Not started | + +### 5.2 Phase 2-5 Test Requirements + +**Runtime Package:** + +| Component | Required Coverage | +|-----------|------------------| +| `RuntimeConfiguration.ts` | 100% | +| `RuntimeConfigurationOptions.ts` | N/A (type only) | +| `IConfigurationProvider.ts` | N/A (interface only) | +| `DefaultConfigurationProvider.ts` | 100% | + +**Tooling Package:** + +| Component | Required Coverage | +|-----------|------------------| +| `ToolingConfiguration.ts` | 100% | +| `ToolingConfigurationOptions.ts` | N/A (type only) | + +**Observability Package:** + +| Component | Required Coverage | +|-----------|------------------| +| `ObservabilityConfiguration.ts` | 100% | +| `ObservabilityConfigurationOptions.ts` | N/A (type only) | +| `IConfigurationProvider.ts` | N/A (interface only) | +| `DefaultConfigurationProvider.ts` | 100% | +| Integration with existing services | All existing tests pass | + +--- + +## 6. Rollout Plan + +### 6.1 Breaking Changes + +**None** - This is a backward-compatible enhancement: +- All existing code continues to work (uses `DefaultConfigurationProvider.instance`) +- Environment variables continue to be the default source +- New `IConfigurationProvider` parameter is optional everywhere + +### 6.2 Documentation Updates + +1. Update `CLAUDE.md` with new configuration pattern +2. Update `docs/design.md` with configuration architecture +3. Add inline documentation to all new classes +4. Create migration guide for consumers wanting custom providers + +### 6.3 Version Considerations + +- This is a **minor version** bump (adds functionality, no breaking changes) +- Deprecation warnings: None needed initially +- Future: Consider deprecating direct `process.env` access in favor of configuration provider + +--- + +## 7. Success Metrics + +| Metric | Target | +|--------|--------| +| All existing tests pass | 100% | +| New configuration code coverage | 100% | +| Overall settings-related coverage | >95% | +| Breaking changes | 0 | +| Multi-tenant scenario supported | Yes | +| `process.env` reads outside configuration classes | **0** | + +### 7.1 Code Quality Enforcement + +**Critical Success Criteria**: No direct `process.env` reads outside of configuration classes. + +To enforce this, add an ESLint rule to prevent future violations: + +```javascript +// eslint.config.mjs +{ + rules: { + 'no-restricted-properties': [ + 'error', + { + object: 'process', + property: 'env', + message: 'Use configuration classes instead of direct process.env access.' + } + ], + // Prevent usage of deprecated methods - causes compile-time errors + '@typescript-eslint/no-deprecated': 'error' + }, + overrides: [ + { + files: ['**/configuration/**/*.ts'], + rules: { + 'no-restricted-properties': 'off' // Allow in configuration classes + } + }, + { + files: ['**/tests/**/*.ts', '**/tests-agent/**/*.ts', '**/*.test.ts', '**/*.spec.ts'], + rules: { + 'no-restricted-properties': 'off', // Allow in test files and samples + '@typescript-eslint/no-deprecated': 'off' // Allow deprecated usage in tests + } + }, + { + files: ['**/agents-a365-tooling/src/Utility.ts'], + rules: { + '@typescript-eslint/no-deprecated': 'off' // Allow internal deprecated calls + } + } + ] +} +``` + +**Note**: The `no-restricted-properties` rule catches the common `process.env.SOMETHING` pattern but won't catch destructuring (`const { env } = process`) or indirect access. These edge cases are unlikely to occur accidentally. + +**Status (Updated 2026-02-04)**: Both ESLint rules have been implemented and are actively enforcing restrictions: +- `environment-utils.ts`: Now delegates to configuration classes +- `McpToolServerConfigurationService.ts`: Now uses `ToolingConfiguration.useToolingManifest` +- `Utility.ts`: URL construction methods are deprecated (use `McpToolServerConfigurationService` instead) + +### 7.2 Deprecated Utility Methods + +The following `Utility` class methods are deprecated and will cause ESLint errors if used in source code: + +| Method | Replacement | +|--------|-------------| +| `GetToolingGatewayForDigitalWorker()` | `McpToolServerConfigurationService.listToolServers()` | +| `GetMcpBaseUrl()` | Use `McpToolServerConfigurationService` | +| `BuildMcpServerUrl()` | Use `McpToolServerConfigurationService` | +| `GetChatHistoryEndpoint()` | `McpToolServerConfigurationService.sendChatHistory()` | + +These methods remain available for backward compatibility but should not be used in new code. + +--- + +## 8. Resolved Design Decisions + +| Question | Decision | +|----------|----------| +| Override values or functions? | **Functions only** - Enables dynamic resolution from async context | +| Caching/lazy evaluation? | **No caching** - Functions called on each access for multi-tenant support | +| Should we add validation? | **No** - Keep current behavior | +| Should hardcoded constants become configurable? | **No** - Keep as hardcoded constants | +| Per-request processor settings? | **Separate class** - `PerRequestSpanProcessorConfiguration` extends `ObservabilityConfiguration` to avoid exposing niche settings | +| Deprecation strategy for utility functions? | **Remove immediately** - Functions not expected to be used by customers | +| Extension packages configuration? | **Create from start** - Makes it easier to add settings later | + +--- + +## 9. Appendix: Complete Environment Variable Reference + +| Variable | Type | Default | Category | +|----------|------|---------|----------| +| `CLUSTER_CATEGORY` | string | `'prod'` | runtime | +| `MCP_PLATFORM_ENDPOINT` | string | `'https://agent365...'` | tooling | +| `MCP_PLATFORM_AUTHENTICATION_SCOPE` | string | `'ea9ffc3e-...'` | tooling | +| `NODE_ENV` | string | `''` | tooling (useToolingManifest) | +| `A365_OBSERVABILITY_SCOPES_OVERRIDE` | string (space-sep) | prod scope | observability | +| `ENABLE_A365_OBSERVABILITY_EXPORTER` | boolean | `false` | observability | +| `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` | boolean | `false` | observability (PerRequestSpanProcessorConfiguration) | +| `A365_OBSERVABILITY_USE_CUSTOM_DOMAIN` | boolean | `false` | observability | +| `A365_OBSERVABILITY_DOMAIN_OVERRIDE` | string | `null` | observability | +| `A365_OBSERVABILITY_LOG_LEVEL` | string | `'none'` | observability | +| `A365_PER_REQUEST_MAX_TRACES` | number | `1000` | observability (PerRequestSpanProcessorConfiguration) | +| `A365_PER_REQUEST_MAX_SPANS_PER_TRACE` | number | `5000` | observability (PerRequestSpanProcessorConfiguration) | +| `A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS` | number | `20` | observability (PerRequestSpanProcessorConfiguration) | diff --git a/eslint.config.mjs b/eslint.config.mjs index 3cfc9a23..f45f9876 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,15 @@ import tseslint from 'typescript-eslint'; export default defineConfig( eslint.configs.recommended, tseslint.configs.recommended, + // Enable typed linting for rules that require type information (e.g., @typescript-eslint/no-deprecated) + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, { "rules": { "@typescript-eslint/no-unused-vars": [ @@ -18,8 +27,43 @@ export default defineConfig( "varsIgnorePattern": "^_", "ignoreRestSiblings": true } + ], + // Prevent usage of deprecated methods, functions, and properties + // This helps ensure we don't accidentally use deprecated APIs within our codebase + "@typescript-eslint/no-deprecated": "error", + // Prevent direct process.env access outside configuration classes + // Use configuration classes instead for better testability and multi-tenant support + "no-restricted-properties": [ + "error", + { + "object": "process", + "property": "env", + "message": "Use configuration classes instead of direct process.env access. See RuntimeConfiguration, ToolingConfiguration, or ObservabilityConfiguration." + } ] } + }, + // Allow process.env in configuration classes (where env vars are centralized) + { + "files": ["**/configuration/**/*.ts"], + "rules": { + "no-restricted-properties": "off" + } + }, + // Allow process.env and deprecated methods in test files and sample applications + { + "files": ["**/tests/**/*.ts", "**/tests-agent/**/*.ts", "**/*.test.ts", "**/*.spec.ts"], + "rules": { + "no-restricted-properties": "off", + "@typescript-eslint/no-deprecated": "off" + } + }, + // Allow deprecated method calls within the Utility class itself (internal implementation) + { + "files": ["**/agents-a365-tooling/src/Utility.ts"], + "rules": { + "@typescript-eslint/no-deprecated": "off" + } } ); diff --git a/packages/agents-a365-observability-extensions-openai/package.json b/packages/agents-a365-observability-extensions-openai/package.json index 69e03418..41c7d05a 100644 --- a/packages/agents-a365-observability-extensions-openai/package.json +++ b/packages/agents-a365-observability-extensions-openai/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@microsoft/agents-a365-observability": "workspace:*", + "@microsoft/agents-a365-runtime": "workspace:*", "@openai/agents": "catalog:", "@opentelemetry/api": "catalog:", "@opentelemetry/instrumentation": "catalog:", diff --git a/packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfiguration.ts b/packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfiguration.ts new file mode 100644 index 00000000..5618c1d0 --- /dev/null +++ b/packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfiguration.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ObservabilityConfiguration } from '@microsoft/agents-a365-observability'; +import { OpenAIObservabilityConfigurationOptions } from './OpenAIObservabilityConfigurationOptions'; + +/** + * Configuration for OpenAI observability extension package. + * Inherits all observability and runtime settings. + * + * ## Why This Class Exists + * + * Although this class currently adds no new settings beyond what ObservabilityConfiguration + * provides, it exists for several important reasons: + * + * 1. **Type Safety**: Allows OpenAI-specific services to declare their dependency on + * `IConfigurationProvider`, making the configuration + * contract explicit and enabling compile-time checking. + * + * 2. **Extension Point**: Provides a clear place to add OpenAI-specific observability settings + * (e.g., trace sampling rates, span attribute limits, custom exporter options) without + * breaking existing code when those needs arise. + * + * 3. **Consistent Pattern**: Maintains symmetry with other extension packages + * (Claude, LangChain tooling extensions), making the SDK easier to understand and navigate. + * + * 4. **Dependency Injection**: Services can be designed to accept this specific + * configuration type, enabling proper IoC patterns and testability. + * + * ## Relationship to OpenAIAgentsInstrumentationConfig + * + * This class is separate from and complementary to `OpenAIAgentsInstrumentationConfig`: + * + * - **OpenAIObservabilityConfiguration**: SDK-wide configuration following the + * configuration provider pattern, supporting function-based overrides and + * environment variable fallbacks. + * + * - **OpenAIAgentsInstrumentationConfig**: OpenTelemetry instrumentation-specific + * configuration passed to `OpenAIAgentsTraceInstrumentor`, following OTel conventions. + * + * @example + * ```typescript + * // Service declares explicit dependency on OpenAI observability configuration + * class OpenAITracingService { + * constructor(private configProvider: IConfigurationProvider) {} + * } + * + * // Future: Add OpenAI-specific settings without breaking changes + * class OpenAIObservabilityConfiguration extends ObservabilityConfiguration { + * get traceSamplingRate(): number { ... } + * } + * ``` + */ +export class OpenAIObservabilityConfiguration extends ObservabilityConfiguration { + constructor(overrides?: OpenAIObservabilityConfigurationOptions) { + super(overrides); + } + + // Inherited: clusterCategory, isDevelopmentEnvironment, observabilityAuthenticationScopes, + // isObservabilityExporterEnabled, useCustomDomainForObservability, + // observabilityDomainOverride, observabilityLogLevel +} diff --git a/packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfigurationOptions.ts b/packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfigurationOptions.ts new file mode 100644 index 00000000..791ddad8 --- /dev/null +++ b/packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfigurationOptions.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ObservabilityConfigurationOptions } from '@microsoft/agents-a365-observability'; + +/** + * OpenAI observability configuration options - extends observability options. + * All overrides are functions called on each property access. + * + * Currently no additional settings; this type exists for future extensibility. + */ +export type OpenAIObservabilityConfigurationOptions = ObservabilityConfigurationOptions; diff --git a/packages/agents-a365-observability-extensions-openai/src/configuration/index.ts b/packages/agents-a365-observability-extensions-openai/src/configuration/index.ts new file mode 100644 index 00000000..f9bb7105 --- /dev/null +++ b/packages/agents-a365-observability-extensions-openai/src/configuration/index.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { OpenAIObservabilityConfiguration } from './OpenAIObservabilityConfiguration'; + +export * from './OpenAIObservabilityConfigurationOptions'; +export * from './OpenAIObservabilityConfiguration'; + +/** + * Shared default provider for OpenAIObservabilityConfiguration. + */ +export const defaultOpenAIObservabilityConfigurationProvider = + new DefaultConfigurationProvider(() => new OpenAIObservabilityConfiguration()); diff --git a/packages/agents-a365-observability-extensions-openai/src/index.ts b/packages/agents-a365-observability-extensions-openai/src/index.ts index a2c48499..77d787ab 100644 --- a/packages/agents-a365-observability-extensions-openai/src/index.ts +++ b/packages/agents-a365-observability-extensions-openai/src/index.ts @@ -8,3 +8,8 @@ export { OpenAIAgentsTraceInstrumentor, OpenAIAgentsInstrumentationConfig } from './OpenAIAgentsTraceInstrumentor'; export { OpenAIAgentsTraceProcessor } from './OpenAIAgentsTraceProcessor'; +export { + OpenAIObservabilityConfiguration, + OpenAIObservabilityConfigurationOptions, + defaultOpenAIObservabilityConfigurationProvider +} from './configuration'; diff --git a/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts b/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts index 8b1abee2..5f6f7ec2 100644 --- a/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts +++ b/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts @@ -4,8 +4,8 @@ // ------------------------------------------------------------------------------ import { TurnContext, Authorization } from '@microsoft/agents-hosting'; -import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runtime'; -import { logger, formatError } from '@microsoft/agents-a365-observability'; +import { logger, formatError, ObservabilityConfiguration, defaultObservabilityConfigurationProvider } from '@microsoft/agents-a365-observability'; +import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; interface CacheEntry { scopes: string[]; @@ -14,11 +14,31 @@ interface CacheEntry { acquiredOn?: number; } -class AgenticTokenCache { +/** + * Cache for agentic authentication tokens used by observability services. + * + * For custom configuration, create a new instance with your own configuration provider: + * ```typescript + * const customCache = new AgenticTokenCache(myConfigProvider); + * ``` + * + * For default configuration using environment variables, use the exported + * `AgenticTokenCacheInstance` singleton. + */ +export class AgenticTokenCache { private readonly _map = new Map(); private readonly _defaultRefreshSkewMs = 60_000; private readonly _defaultMaxTokenAgeMs = 3_600_000; private readonly _keyLocks = new Map>(); + private readonly _configProvider: IConfigurationProvider; + + /** + * Construct an AgenticTokenCache. + * @param configProvider Optional configuration provider. Defaults to defaultObservabilityConfigurationProvider if not specified. + */ + constructor(configProvider?: IConfigurationProvider) { + this._configProvider = configProvider ?? defaultObservabilityConfigurationProvider; + } public static makeKey(agentId: string, tenantId: string): string { return `${agentId}:${tenantId}`; @@ -59,7 +79,7 @@ class AgenticTokenCache { return this.withKeyLock(key, async () => { let entry = this._map.get(key); if (!entry) { - const effectiveScopes = (scopes && scopes.length > 0) ? scopes : getObservabilityAuthenticationScope(); + const effectiveScopes = (scopes && scopes.length > 0) ? scopes : [...this._configProvider.getConfiguration().observabilityAuthenticationScopes]; if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) { logger.error('[AgenticTokenCache] No valid scopes'); return; @@ -197,5 +217,21 @@ class AgenticTokenCache { } } +/** + * Default singleton instance of AgenticTokenCache using the default configuration provider. + * + * This instance uses `defaultObservabilityConfigurationProvider` which reads from + * environment variables. It is suitable for: + * - Single-tenant deployments + * - Multi-tenant deployments using dynamic override functions in the configuration + * + * **For custom configuration:** Create a new `AgenticTokenCache` instance with your + * own `IConfigurationProvider`: + * ```typescript + * import { AgenticTokenCache } from '@microsoft/agents-a365-observability-hosting'; + * + * const customCache = new AgenticTokenCache(myCustomConfigProvider); + * ``` + */ export const AgenticTokenCacheInstance = new AgenticTokenCache(); export default AgenticTokenCacheInstance; diff --git a/packages/agents-a365-observability-hosting/src/index.ts b/packages/agents-a365-observability-hosting/src/index.ts index bf7e4ce4..2484a5eb 100644 --- a/packages/agents-a365-observability-hosting/src/index.ts +++ b/packages/agents-a365-observability-hosting/src/index.ts @@ -6,4 +6,4 @@ export * from './utils/BaggageBuilderUtils'; export * from './utils/ScopeUtils'; export * from './utils/TurnContextUtils'; -export { AgenticTokenCacheInstance } from './caching/AgenticTokenCache'; +export { AgenticTokenCache, AgenticTokenCacheInstance } from './caching/AgenticTokenCache'; diff --git a/packages/agents-a365-observability/PER_REQUEST_EXPORT.md b/packages/agents-a365-observability/PER_REQUEST_EXPORT.md index 3e9124fc..81c4786d 100644 --- a/packages/agents-a365-observability/PER_REQUEST_EXPORT.md +++ b/packages/agents-a365-observability/PER_REQUEST_EXPORT.md @@ -51,10 +51,12 @@ Notes: ## Guardrails (recommended) -Per-request buffering can increase memory usage and cause export bursts during traffic spikes. `PerRequestSpanProcessor` includes guardrails that you can tune via env vars: +Per-request buffering can increase memory usage and cause export bursts during traffic spikes. `PerRequestSpanProcessor` includes guardrails that you can tune via env vars or via `PerRequestSpanProcessorConfiguration` overrides: - `A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS` (default `20`): caps concurrent exports across requests/traces. - `A365_PER_REQUEST_MAX_TRACES` (default `1000`): caps concurrently buffered traces. - `A365_PER_REQUEST_MAX_SPANS_PER_TRACE` (default `5000`): caps buffered ended spans per trace. -Set to `0` (or negative) to disable a specific guardrail. \ No newline at end of file +Set to `0` (or negative) to disable a specific guardrail. + +These settings live on `PerRequestSpanProcessorConfiguration` (which extends `ObservabilityConfiguration`) and are not exposed on the common `ObservabilityConfiguration` class. Use `defaultPerRequestSpanProcessorConfigurationProvider` to access these settings programmatically. \ No newline at end of file diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index f73c5148..8a16d793 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -12,6 +12,7 @@ The observability package provides OpenTelemetry-based distributed tracing infra ┌─────────────────────────────────────────────────────────────────┐ │ Public API │ │ ObservabilityManager | Builder | Scopes | BaggageBuilder │ +│ ObservabilityConfiguration | PerRequestSpanProcessorConfiguration│ └─────────────────────────────────────────────────────────────────┘ │ ▼ @@ -415,8 +416,75 @@ src/ - `@opentelemetry/semantic-conventions` - Semantic attribute keys - `@microsoft/agents-a365-runtime` - Cluster category type +## Configuration + +The observability package provides configuration via `ObservabilityConfiguration`, which extends `RuntimeConfiguration`: + +```typescript +import { + ObservabilityConfiguration, + defaultObservabilityConfigurationProvider +} from '@microsoft/agents-a365-observability'; + +// Using the default provider (reads from env vars) +const config = defaultObservabilityConfigurationProvider.getConfiguration(); +console.log(config.isObservabilityExporterEnabled); // Enable/disable exporter +console.log(config.observabilityAuthenticationScopes); // Auth scopes for token exchange +// Custom configuration with overrides +const customConfig = new ObservabilityConfiguration({ + isObservabilityExporterEnabled: () => true, + observabilityAuthenticationScopes: () => ['custom-scope/.default'], + observabilityDomainOverride: () => 'https://custom.domain' +}); +``` + +**ObservabilityConfiguration Properties:** + +| Property | Env Variable | Default | Description | +|----------|--------------|---------|-------------| +| `observabilityAuthenticationScopes` | `A365_OBSERVABILITY_SCOPES_OVERRIDE` | `['https://api.powerplatform.com/.default']` | OAuth scopes for observability auth | +| `isObservabilityExporterEnabled` | `ENABLE_A365_OBSERVABILITY_EXPORTER` | `false` | Enable Agent365 exporter | +| `useCustomDomainForObservability` | `A365_OBSERVABILITY_USE_CUSTOM_DOMAIN` | `false` | Use custom domain for export | +| `observabilityDomainOverride` | `A365_OBSERVABILITY_DOMAIN_OVERRIDE` | `null` | Custom domain URL override | +| `observabilityLogLevel` | `A365_OBSERVABILITY_LOG_LEVEL` | `none` | Internal logging level | +| `clusterCategory` | `CLUSTER_CATEGORY` | `prod` | (Inherited) Environment cluster | +| `isDevelopmentEnvironment` | - | Derived | (Inherited) true if cluster is 'local' or 'dev' | +| `isNodeEnvDevelopment` | `NODE_ENV` | `false` | (Inherited) true if NODE_ENV='development' | + +### PerRequestSpanProcessorConfiguration + +`PerRequestSpanProcessorConfiguration` extends `ObservabilityConfiguration` and adds guardrail settings specific to the `PerRequestSpanProcessor`. These settings are separated from the common `ObservabilityConfiguration` because the per-request span processor is only used in specific scenarios. + +```typescript +import { + PerRequestSpanProcessorConfiguration, + defaultPerRequestSpanProcessorConfigurationProvider +} from '@microsoft/agents-a365-observability'; + +const config = defaultPerRequestSpanProcessorConfigurationProvider.getConfiguration(); +console.log(config.isPerRequestExportEnabled); // Per-request export mode (default: false) +console.log(config.perRequestMaxTraces); // Max buffered traces (default: 1000) +``` + +**PerRequestSpanProcessorConfiguration Properties (in addition to all ObservabilityConfiguration properties):** + +| Property | Env Variable | Default | Description | +|----------|--------------|---------|-------------| +| `isPerRequestExportEnabled` | `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` | `false` | Enable per-request export mode | +| `perRequestMaxTraces` | `A365_PER_REQUEST_MAX_TRACES` | `1000` | Max buffered traces per request | +| `perRequestMaxSpansPerTrace` | `A365_PER_REQUEST_MAX_SPANS_PER_TRACE` | `5000` | Max spans per trace | +| `perRequestMaxConcurrentExports` | `A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS` | `20` | Max concurrent export operations | + ## Environment Variables -| Variable | Purpose | -|----------|---------| -| `ENABLE_A365_OBSERVABILITY_EXPORTER` | Enable/disable Agent365 exporter | +| Variable | Purpose | Default | +|----------|---------|---------| +| `ENABLE_A365_OBSERVABILITY_EXPORTER` | Enable/disable Agent365 exporter | `false` | +| `A365_OBSERVABILITY_SCOPES_OVERRIDE` | Override auth scopes (space-separated) | Production scope | +| `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` | Enable per-request export mode | `false` | +| `A365_OBSERVABILITY_USE_CUSTOM_DOMAIN` | Use custom domain for export | `false` | +| `A365_OBSERVABILITY_DOMAIN_OVERRIDE` | Custom domain URL | - | +| `A365_OBSERVABILITY_LOG_LEVEL` | Internal log level | `none` | +| `A365_PER_REQUEST_MAX_TRACES` | Max buffered traces (`PerRequestSpanProcessorConfiguration`) | `1000` | +| `A365_PER_REQUEST_MAX_SPANS_PER_TRACE` | Max spans per trace (`PerRequestSpanProcessorConfiguration`) | `5000` | +| `A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS` | Max concurrent exports (`PerRequestSpanProcessorConfiguration`) | `20` | diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index 0255d389..177bcd49 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -128,7 +128,7 @@ export class ObservabilityBuilder { if (this.options.exporterOptions) { Object.assign(opts, this.options.exporterOptions); } - opts.clusterCategory = this.options.clusterCategory || opts.clusterCategory || 'prod'; + opts.clusterCategory = this.options.clusterCategory || opts.clusterCategory || ClusterCategory.prod; if (this.options.tokenResolver) { opts.tokenResolver = this.options.tokenResolver; } @@ -150,7 +150,7 @@ export class ObservabilityBuilder { if (this.options.exporterOptions) { Object.assign(opts, this.options.exporterOptions); } - opts.clusterCategory = this.options.clusterCategory || opts.clusterCategory || 'prod'; + opts.clusterCategory = this.options.clusterCategory || opts.clusterCategory || ClusterCategory.prod; // For per-request export, token is retrieved from OTel Context by Agent365Exporter // using getExportToken(), so no tokenResolver is needed here diff --git a/packages/agents-a365-observability/src/configuration/ObservabilityConfiguration.ts b/packages/agents-a365-observability/src/configuration/ObservabilityConfiguration.ts new file mode 100644 index 00000000..8060b83e --- /dev/null +++ b/packages/agents-a365-observability/src/configuration/ObservabilityConfiguration.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RuntimeConfiguration } from '@microsoft/agents-a365-runtime'; +import { ObservabilityConfigurationOptions } from './ObservabilityConfigurationOptions'; + +// Default constants +const PROD_OBSERVABILITY_SCOPE = 'https://api.powerplatform.com/.default'; + +/** + * Configuration for observability package. + * Inherits runtime settings and adds observability-specific settings. + */ +export class ObservabilityConfiguration extends RuntimeConfiguration { + protected get observabilityOverrides(): ObservabilityConfigurationOptions { + return this.overrides as ObservabilityConfigurationOptions; + } + + constructor(overrides?: ObservabilityConfigurationOptions) { + super(overrides); + } + + // Inherited: clusterCategory, isDevelopmentEnvironment, isNodeEnvDevelopment + + /** + * Gets the observability authentication scopes. + * Used by AgenticTokenCache for observability service authentication. + */ + get observabilityAuthenticationScopes(): readonly string[] { + const result = this.observabilityOverrides.observabilityAuthenticationScopes?.(); + if (result !== undefined) { + return result; + } + const override = process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE; + if (override?.trim()) { + return override.trim().split(/\s+/); + } + return [PROD_OBSERVABILITY_SCOPE]; + } + + get isObservabilityExporterEnabled(): boolean { + const result = this.observabilityOverrides.isObservabilityExporterEnabled?.(); + if (result !== undefined) return result; + return RuntimeConfiguration.parseEnvBoolean(process.env.ENABLE_A365_OBSERVABILITY_EXPORTER); + } + + get useCustomDomainForObservability(): boolean { + const result = this.observabilityOverrides.useCustomDomainForObservability?.(); + if (result !== undefined) return result; + return RuntimeConfiguration.parseEnvBoolean(process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN); + } + + get observabilityDomainOverride(): string | null { + const result = this.observabilityOverrides.observabilityDomainOverride?.(); + if (result !== undefined) { + return result; + } + const override = process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; + if (override?.trim()) { + return override.trim().replace(/\/+$/, ''); + } + return null; + } + + get observabilityLogLevel(): string { + return this.observabilityOverrides.observabilityLogLevel?.() + ?? process.env.A365_OBSERVABILITY_LOG_LEVEL + ?? 'none'; + } +} diff --git a/packages/agents-a365-observability/src/configuration/ObservabilityConfigurationOptions.ts b/packages/agents-a365-observability/src/configuration/ObservabilityConfigurationOptions.ts new file mode 100644 index 00000000..c9c2e3e3 --- /dev/null +++ b/packages/agents-a365-observability/src/configuration/ObservabilityConfigurationOptions.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RuntimeConfigurationOptions } from '@microsoft/agents-a365-runtime'; + +/** + * Observability configuration options - extends runtime options. + * All overrides are functions called on each property access. + * + * Inherited from RuntimeConfigurationOptions: + * - clusterCategory + * - isNodeEnvDevelopment + * + * Note: `isDevelopmentEnvironment` is a derived getter on the configuration class + * (based on clusterCategory), not an overridable option. + */ +export type ObservabilityConfigurationOptions = RuntimeConfigurationOptions & { + /** + * Override for observability authentication scopes. + * Used by AgenticTokenCache for observability service authentication. + * + * @returns Array of OAuth scopes for observability service authentication. + * @envvar A365_OBSERVABILITY_SCOPES_OVERRIDE - Space-separated list of scopes. + * @default ['https://api.powerplatform.com/.default'] + */ + observabilityAuthenticationScopes?: () => string[]; + + /** + * Override to enable/disable the Agent365 observability span exporter. + * When enabled, spans are exported to the Agent365 observability service. + * + * @returns `true` to enable the exporter, `false` to disable. + * @envvar ENABLE_A365_OBSERVABILITY_EXPORTER - 'true', '1', 'yes', 'on' to enable. + * @default false + */ + isObservabilityExporterEnabled?: () => boolean; + + /** + * Override to enable/disable custom domain for observability endpoints. + * When enabled, uses `observabilityDomainOverride` instead of the default endpoint. + * + * @returns `true` to use custom domain, `false` to use default. + * @envvar A365_OBSERVABILITY_USE_CUSTOM_DOMAIN - 'true', '1', 'yes', 'on' to enable. + * @default false + */ + useCustomDomainForObservability?: () => boolean; + + /** + * Override for the custom observability domain/endpoint. + * Only used when `useCustomDomainForObservability` is true. + * Trailing slashes are automatically removed. + * + * @returns Custom domain URL string, or `null` for no override. + * @envvar A365_OBSERVABILITY_DOMAIN_OVERRIDE - Full URL of custom endpoint. + * @default null + */ + observabilityDomainOverride?: () => string | null; + + /** + * Override for the internal SDK log level. + * Controls which log messages are output by the observability SDK's internal logger. + * + * Supported values (pipe-separated for multiple): + * - 'none' - No logging (default) + * - 'info' - Information messages + * - 'warn' - Warning messages + * - 'error' - Error messages + * - 'info|warn|error' - All messages + * + * @returns Log level string. + * @envvar A365_OBSERVABILITY_LOG_LEVEL + * @default 'none' + */ + observabilityLogLevel?: () => string; +}; diff --git a/packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfiguration.ts b/packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfiguration.ts new file mode 100644 index 00000000..669b7fb8 --- /dev/null +++ b/packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfiguration.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RuntimeConfiguration } from '@microsoft/agents-a365-runtime'; +import { ObservabilityConfiguration } from './ObservabilityConfiguration'; +import { PerRequestSpanProcessorConfigurationOptions } from './PerRequestSpanProcessorConfigurationOptions'; + +/** Guardrails to prevent unbounded memory growth / export bursts. Used for PerRequestSpanProcessor only. */ +const DEFAULT_MAX_BUFFERED_TRACES = 1000; +const DEFAULT_MAX_SPANS_PER_TRACE = 5000; +const DEFAULT_MAX_CONCURRENT_EXPORTS = 20; + +/** + * Configuration for PerRequestSpanProcessor. + * Inherits all observability and runtime settings, and adds per-request processor guardrails. + * + * This is separated from ObservabilityConfiguration because PerRequestSpanProcessor + * is used only in specific scenarios and these settings should not be exposed + * in the common ObservabilityConfiguration. + */ +export class PerRequestSpanProcessorConfiguration extends ObservabilityConfiguration { + protected get perRequestOverrides(): PerRequestSpanProcessorConfigurationOptions { + return this.overrides as PerRequestSpanProcessorConfigurationOptions; + } + + constructor(overrides?: PerRequestSpanProcessorConfigurationOptions) { + super(overrides); + } + + // Inherited: clusterCategory, isDevelopmentEnvironment, isNodeEnvDevelopment, + // observabilityAuthenticationScopes, isObservabilityExporterEnabled, + // useCustomDomainForObservability, observabilityDomainOverride, observabilityLogLevel + + get isPerRequestExportEnabled(): boolean { + const result = this.perRequestOverrides.isPerRequestExportEnabled?.(); + if (result !== undefined) return result; + return RuntimeConfiguration.parseEnvBoolean(process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT); + } + + get perRequestMaxTraces(): number { + const value = this.perRequestOverrides.perRequestMaxTraces?.() + ?? RuntimeConfiguration.parseEnvInt(process.env.A365_PER_REQUEST_MAX_TRACES, DEFAULT_MAX_BUFFERED_TRACES); + return value > 0 ? value : DEFAULT_MAX_BUFFERED_TRACES; + } + + get perRequestMaxSpansPerTrace(): number { + const value = this.perRequestOverrides.perRequestMaxSpansPerTrace?.() + ?? RuntimeConfiguration.parseEnvInt(process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE, DEFAULT_MAX_SPANS_PER_TRACE); + return value > 0 ? value : DEFAULT_MAX_SPANS_PER_TRACE; + } + + get perRequestMaxConcurrentExports(): number { + const value = this.perRequestOverrides.perRequestMaxConcurrentExports?.() + ?? RuntimeConfiguration.parseEnvInt(process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS, DEFAULT_MAX_CONCURRENT_EXPORTS); + return value > 0 ? value : DEFAULT_MAX_CONCURRENT_EXPORTS; + } +} diff --git a/packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfigurationOptions.ts b/packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfigurationOptions.ts new file mode 100644 index 00000000..4dd399e2 --- /dev/null +++ b/packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfigurationOptions.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ObservabilityConfigurationOptions } from './ObservabilityConfigurationOptions'; + +/** + * Configuration options for PerRequestSpanProcessor - extends observability options. + * All overrides are functions called on each property access. + * + * Inherited from ObservabilityConfigurationOptions: + * - observabilityAuthenticationScopes, isObservabilityExporterEnabled, + * useCustomDomainForObservability, observabilityDomainOverride, observabilityLogLevel + * + * Inherited from RuntimeConfigurationOptions: + * - clusterCategory, isDevelopmentEnvironment, isNodeEnvDevelopment + */ +export type PerRequestSpanProcessorConfigurationOptions = ObservabilityConfigurationOptions & { + /** + * Override to enable/disable per-request export mode. + * When enabled, spans are buffered per-request and exported when the request completes, + * rather than using a batch processor. + * + * @returns `true` to enable per-request export, `false` for batch export. + * @envvar ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT - 'true', '1', 'yes', 'on' to enable. + * @default false + */ + isPerRequestExportEnabled?: () => boolean; + + /** + * Override for maximum number of traces to buffer in per-request export mode. + * When this limit is reached, oldest traces are dropped. + * Values <= 0 are ignored and the default is used. + * + * @returns Maximum number of buffered traces. + * @envvar A365_PER_REQUEST_MAX_TRACES + * @default 1000 + */ + perRequestMaxTraces?: () => number; + + /** + * Override for maximum number of spans per trace in per-request export mode. + * Traces with more spans than this limit will have excess spans dropped. + * Values <= 0 are ignored and the default is used. + * + * @returns Maximum spans per trace. + * @envvar A365_PER_REQUEST_MAX_SPANS_PER_TRACE + * @default 5000 + */ + perRequestMaxSpansPerTrace?: () => number; + + /** + * Override for maximum concurrent export operations in per-request export mode. + * Limits the number of parallel HTTP requests to the observability service. + * Values <= 0 are ignored and the default is used. + * + * @returns Maximum concurrent exports. + * @envvar A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS + * @default 20 + */ + perRequestMaxConcurrentExports?: () => number; +}; diff --git a/packages/agents-a365-observability/src/configuration/index.ts b/packages/agents-a365-observability/src/configuration/index.ts new file mode 100644 index 00000000..00f35ffe --- /dev/null +++ b/packages/agents-a365-observability/src/configuration/index.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { ObservabilityConfiguration } from './ObservabilityConfiguration'; +import { PerRequestSpanProcessorConfiguration } from './PerRequestSpanProcessorConfiguration'; + +export * from './ObservabilityConfigurationOptions'; +export * from './ObservabilityConfiguration'; +export * from './PerRequestSpanProcessorConfigurationOptions'; +export * from './PerRequestSpanProcessorConfiguration'; + +/** + * Shared default provider for ObservabilityConfiguration. + */ +export const defaultObservabilityConfigurationProvider = + new DefaultConfigurationProvider(() => new ObservabilityConfiguration()); + +/** + * Shared default provider for PerRequestSpanProcessorConfiguration. + */ +export const defaultPerRequestSpanProcessorConfigurationProvider = + new DefaultConfigurationProvider(() => new PerRequestSpanProcessorConfiguration()); diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index a56e4fc3..d0403613 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -41,4 +41,8 @@ export { OpenTelemetryScope } from './tracing/scopes/OpenTelemetryScope'; export { ExecuteToolScope } from './tracing/scopes/ExecuteToolScope'; export { InvokeAgentScope } from './tracing/scopes/InvokeAgentScope'; export { InferenceScope} from './tracing/scopes/InferenceScope'; -export { logger, setLogger, getLogger, resetLogger, ILogger, formatError } from './utils/logging'; +export { logger, setLogger, getLogger, resetLogger, formatError } from './utils/logging'; +export type { ILogger } from './utils/logging'; + +// Configuration +export * from './configuration'; diff --git a/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts b/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts index fccb3a6d..da170fa1 100644 --- a/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts +++ b/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts @@ -5,7 +5,9 @@ import { context, type Context } from '@opentelemetry/api'; import type { ReadableSpan, SpanProcessor, SpanExporter } from '@opentelemetry/sdk-trace-base'; +import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; import logger from '../utils/logging'; +import { PerRequestSpanProcessorConfiguration, defaultPerRequestSpanProcessorConfigurationProvider } from '../configuration'; /** Default grace period (ms) to wait for child spans after root span ends */ const DEFAULT_FLUSH_GRACE_MS = 250; @@ -13,18 +15,6 @@ const DEFAULT_FLUSH_GRACE_MS = 250; /** Default maximum age (ms) for a trace before forcing flush */ const DEFAULT_MAX_TRACE_AGE_MS = 30 * 60 * 1000; // 30 minutes -/** Guardrails to prevent unbounded memory growth / export bursts */ -const DEFAULT_MAX_BUFFERED_TRACES = 1000; -const DEFAULT_MAX_SPANS_PER_TRACE = 5000; -const DEFAULT_MAX_CONCURRENT_EXPORTS = 20; - -function readEnvInt(name: string, fallback: number): number { - const raw = process.env[name]; - if (!raw) return fallback; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) ? parsed : fallback; -} - function isRootSpan(span: ReadableSpan): boolean { return !span.parentSpanContext; } @@ -58,16 +48,26 @@ export class PerRequestSpanProcessor implements SpanProcessor { private inFlightExports = 0; private exportWaiters: Array<() => void> = []; + /** + * Construct a PerRequestSpanProcessor. + * @param exporter The span exporter to use. + * @param flushGraceMs Grace period (ms) to wait for child spans after root span ends. + * @param maxTraceAgeMs Maximum age (ms) for a trace before forcing flush. + * @param configProvider Optional configuration provider. Defaults to defaultPerRequestSpanProcessorConfigurationProvider if not specified. + */ constructor( private readonly exporter: SpanExporter, private readonly flushGraceMs: number = DEFAULT_FLUSH_GRACE_MS, - private readonly maxTraceAgeMs: number = DEFAULT_MAX_TRACE_AGE_MS + private readonly maxTraceAgeMs: number = DEFAULT_MAX_TRACE_AGE_MS, + configProvider?: IConfigurationProvider ) { - // Defaults are intentionally high but bounded; override via env vars if needed. + // Defaults are intentionally high but bounded; override via configuration if needed. // Set to 0 (or negative) to disable a guardrail. - this.maxBufferedTraces = readEnvInt('A365_PER_REQUEST_MAX_TRACES', DEFAULT_MAX_BUFFERED_TRACES); - this.maxSpansPerTrace = readEnvInt('A365_PER_REQUEST_MAX_SPANS_PER_TRACE', DEFAULT_MAX_SPANS_PER_TRACE); - this.maxConcurrentExports = readEnvInt('A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS', DEFAULT_MAX_CONCURRENT_EXPORTS); + const effectiveConfigProvider = configProvider ?? defaultPerRequestSpanProcessorConfigurationProvider; + const config = effectiveConfigProvider.getConfiguration(); + this.maxBufferedTraces = config.perRequestMaxTraces; + this.maxSpansPerTrace = config.perRequestMaxSpansPerTrace; + this.maxConcurrentExports = config.perRequestMaxConcurrentExports; } onStart(span: ReadableSpan, ctx: Context): void { diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index 749fa396..886103e5 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -4,6 +4,7 @@ // ------------------------------------------------------------------------------ import { ClusterCategory } from '@microsoft/agents-a365-runtime'; + /** * A function that resolves and returns an authentication token for the given agent and tenant. * Implementations may perform synchronous lookup (e.g., in-memory cache) or asynchronous network calls. @@ -17,7 +18,7 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null * These values tune batching, timeouts, token acquisition and endpoint shape. All properties have sensible * defaults so callers can usually construct without arguments and override selectively. * - * @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. "preprod", "prod", default to "prod"). + * @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. ClusterCategory.preprod, ClusterCategory.prod, default to ClusterCategory.prod). * @property {TokenResolver} [tokenResolver] Optional delegate to obtain an auth token. If omitted the exporter will * fall back to reading the cached token (AgenticTokenCacheInstance.getObservabilityToken). * @property {boolean} [useS2SEndpoint] When true, exporter will POST to the S2S path (/maven/agent365/service/agents/{agentId}/traces). @@ -27,8 +28,8 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null * @property {number} maxExportBatchSize Maximum number of spans per export batch. */ export class Agent365ExporterOptions { - /** Environment / cluster category (e.g. "preprod", "prod"). */ - public clusterCategory: ClusterCategory | string = 'prod'; + /** Environment / cluster category (e.g. ClusterCategory.preprod, ClusterCategory.prod). */ + public clusterCategory: ClusterCategory | string = ClusterCategory.prod; /** Optional delegate to resolve auth token used by exporter */ public tokenResolver?: TokenResolver; // Optional if ENABLE_A365_OBSERVABILITY_EXPORTER is false diff --git a/packages/agents-a365-observability/src/tracing/exporter/utils.ts b/packages/agents-a365-observability/src/tracing/exporter/utils.ts index b75e5f27..9ae3e888 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/utils.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/utils.ts @@ -4,9 +4,15 @@ import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { SpanKind, SpanStatusCode } from '@opentelemetry/api'; -import { ClusterCategory } from '@microsoft/agents-a365-runtime'; +import { ClusterCategory, IConfigurationProvider } from '@microsoft/agents-a365-runtime'; import { OpenTelemetryConstants } from '../constants'; import logger from '../../utils/logging'; +import { + ObservabilityConfiguration, + defaultObservabilityConfigurationProvider, + PerRequestSpanProcessorConfiguration, + defaultPerRequestSpanProcessorConfigurationProvider +} from '../../configuration'; /** * Convert trace ID to hex string format @@ -111,11 +117,13 @@ export function partitionByIdentity( /** * Check if Agent 365 exporter is enabled via environment variable + * @param configProvider Optional configuration provider. Defaults to defaultObservabilityConfigurationProvider if not specified. */ -export function isAgent365ExporterEnabled(): boolean { - const a365Env = process.env[OpenTelemetryConstants.ENABLE_A365_OBSERVABILITY_EXPORTER]?.toLowerCase() || ''; - const validValues = ['true', '1', 'yes', 'on']; - const enabled: boolean = validValues.includes(a365Env); +export function isAgent365ExporterEnabled( + configProvider?: IConfigurationProvider +): boolean { + const provider = configProvider ?? defaultObservabilityConfigurationProvider; + const enabled = provider.getConfiguration().isObservabilityExporterEnabled; logger.info(`[Agent365Exporter] Agent 365 exporter enabled: ${enabled}`); return enabled; } @@ -124,11 +132,13 @@ export function isAgent365ExporterEnabled(): boolean { * Check if per-request export is enabled via environment variable. * When enabled, the PerRequestSpanProcessor is used instead of BatchSpanProcessor. * The token is passed via OTel Context (async local storage) at export time. + * @param configProvider Optional configuration provider. Defaults to defaultPerRequestSpanProcessorConfigurationProvider if not specified. */ -export function isPerRequestExportEnabled(): boolean { - const value = process.env[OpenTelemetryConstants.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT]?.toLowerCase() || ''; - const validValues = ['true', '1', 'yes', 'on']; - const enabled: boolean = validValues.includes(value); +export function isPerRequestExportEnabled( + configProvider?: IConfigurationProvider +): boolean { + const provider = configProvider ?? defaultPerRequestSpanProcessorConfigurationProvider; + const enabled = provider.getConfiguration().isPerRequestExportEnabled; logger.info(`[Agent365Exporter] Per-request export enabled: ${enabled}`); return enabled; } @@ -137,11 +147,13 @@ export function isPerRequestExportEnabled(): boolean { * Single toggle to use custom domain for observability export. * When true exporter will send traces to custom Agent365 service endpoint * and include x-ms-tenant-id in headers. + * @param configProvider Optional configuration provider. Defaults to defaultObservabilityConfigurationProvider if not specified. */ -export function useCustomDomainForObservability(): boolean { - const value = process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN?.toLowerCase() || ''; - const validValues = ['true', '1', 'yes', 'on']; - const enabled = validValues.includes(value); +export function useCustomDomainForObservability( + configProvider?: IConfigurationProvider +): boolean { + const provider = configProvider ?? defaultObservabilityConfigurationProvider; + const enabled = provider.getConfiguration().useCustomDomainForObservability; logger.info(`[Agent365Exporter] Use custom domain for observability: ${enabled}`); return enabled; } @@ -163,15 +175,13 @@ export function resolveAgent365Endpoint(clusterCategory: ClusterCategory): strin * Internal development and test clusters can override this by setting the * `A365_OBSERVABILITY_DOMAIN_OVERRIDE` environment variable. When set to a * non-empty value, that value is used as the base URI regardless of cluster category. Otherwise, null is returned. + * @param configProvider Optional configuration provider. Defaults to defaultObservabilityConfigurationProvider if not specified. */ -export function getAgent365ObservabilityDomainOverride(): string | null { - const override = process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; - - if (override && override.trim().length > 0) { - // Normalize to avoid double slashes when concatenating paths - return override.trim().replace(/\/+$/, ''); - } - return null; +export function getAgent365ObservabilityDomainOverride( + configProvider?: IConfigurationProvider +): string | null { + const provider = configProvider ?? defaultObservabilityConfigurationProvider; + return provider.getConfiguration().observabilityDomainOverride; } diff --git a/packages/agents-a365-observability/src/tracing/util.ts b/packages/agents-a365-observability/src/tracing/util.ts index 63e2638a..2c051f2b 100644 --- a/packages/agents-a365-observability/src/tracing/util.ts +++ b/packages/agents-a365-observability/src/tracing/util.ts @@ -3,7 +3,8 @@ // Licensed under the MIT License. // ------------------------------------------------------------------------------ -import { OpenTelemetryConstants } from './constants'; +import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { ObservabilityConfiguration, defaultObservabilityConfigurationProvider } from '../configuration'; /** * Check if exporter is enabled via environment variables. @@ -11,14 +12,12 @@ import { OpenTelemetryConstants } from './constants'; * NOTE: Exporter-specific helpers have been moved to * tracing/exporter/utils.ts. This file remains only for any * non-exporter tracing utilities that may be added in the future. + * + * @param configProvider Optional configuration provider. Defaults to defaultObservabilityConfigurationProvider if not specified. */ -export const isAgent365ExporterEnabled: () => boolean = (): boolean => { - const enableA365Exporter = process.env[OpenTelemetryConstants.ENABLE_A365_OBSERVABILITY_EXPORTER]?.toLowerCase(); - - return ( - enableA365Exporter === 'true' || - enableA365Exporter === '1' || - enableA365Exporter === 'yes' || - enableA365Exporter === 'on' - ); +export const isAgent365ExporterEnabled = ( + configProvider?: IConfigurationProvider +): boolean => { + const provider = configProvider ?? defaultObservabilityConfigurationProvider; + return provider.getConfiguration().isObservabilityExporterEnabled; }; diff --git a/packages/agents-a365-observability/src/utils/logging.ts b/packages/agents-a365-observability/src/utils/logging.ts index 104bdd57..b4c0c81f 100644 --- a/packages/agents-a365-observability/src/utils/logging.ts +++ b/packages/agents-a365-observability/src/utils/logging.ts @@ -1,6 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { + defaultObservabilityConfigurationProvider, + ObservabilityConfiguration +} from '../configuration'; + /** * Custom logger interface for Agent 365 observability * Implement this interface to support logging backends @@ -38,38 +44,39 @@ export function formatError(error: unknown): string { return String(error); } +const LOG_LEVELS: Record = { + none: 0, + info: 1, + warn: 2, + error: 3 +}; + /** - * Console-based logger adapter that wraps console.log, console.warn, console.error + * Parse log level string into a set of enabled log levels. + * Supports pipe-separated values like "info|warn|error". */ -class ConsoleLogger implements ILogger { - constructor( - private prefix = '[A365]', - private useConsoleLog = false, - private useConsoleWarn = false, - private useConsoleError = false - ) {} - - info(message: string, ...args: unknown[]): void { - if (this.useConsoleLog) { - console.log(`${this.prefix} ${message}`, ...args); +function parseLogLevel(level: string): Set { + const levels = new Set(); + const levelStrings = level.toLowerCase().trim().split('|'); + + for (const levelString of levelStrings) { + const normalizedLevel = levelString.trim(); + const levelValue = LOG_LEVELS[normalizedLevel]; + if (levelValue !== undefined) { + levels.add(levelValue); } } - warn(message: string, ...args: unknown[]): void { - if (this.useConsoleWarn) { - console.warn(`${this.prefix} ${message}`, ...args); - } + // If no valid levels found, default to none + if (levels.size === 0) { + levels.add(LOG_LEVELS.none); } - error(message: string, ...args: unknown[]): void { - if (this.useConsoleError) { - console.error(`${this.prefix} ${message}`, ...args); - } - } + return levels; } /** - * Default console-based logger implementation with environment variable control + * Default console-based logger implementation with configuration provider support. * * Environment Variable: * A365_OBSERVABILITY_LOG_LEVEL=none|info|warn|error (default: none) @@ -85,85 +92,29 @@ class ConsoleLogger implements ILogger { * warn|error = warn and error messages * info|warn|error = all message types */ -class DefaultLogger implements ILogger { - private enabledLogLevels: Set; - private consoleLogger: ConsoleLogger; - - constructor() { - this.enabledLogLevels = this.parseLogLevel(process.env.A365_OBSERVABILITY_LOG_LEVEL || 'none'); - this.consoleLogger = new ConsoleLogger('[INFO]', false, false, false); - } - - /** - * Console-based logger adapter that wraps console.log, console.warn, console.error - */ - private ConsoleLogger = class ConsoleLogger implements ILogger { - constructor( - private prefix = '[A365]', - private useConsoleLog = false, - private useConsoleWarn = false, - private useConsoleError = false - ) {} - - info(message: string, ...args: unknown[]): void { - if (this.useConsoleLog) { - console.log(`${this.prefix} ${message}`, ...args); - } - } - - warn(message: string, ...args: unknown[]): void { - if (this.useConsoleWarn) { - console.warn(`${this.prefix} ${message}`, ...args); - } - } - - error(message: string, ...args: unknown[]): void { - if (this.useConsoleError) { - console.error(`${this.prefix} ${message}`, ...args); - } - } - }; - - private parseLogLevel(level: string): Set { - const LOG_LEVELS: Record = { - none: 0, - info: 1, - warn: 2, - error: 3 - }; - - const levels = new Set(); - const levelStrings = level.toLowerCase().trim().split('|'); - - for (const levelString of levelStrings) { - const normalizedLevel = levelString.trim(); - const levelValue = LOG_LEVELS[normalizedLevel]; - if (levelValue !== undefined) { - levels.add(levelValue); - } - } - - if (levels.size === 0) { - levels.add(LOG_LEVELS.none); - } +export class DefaultLogger implements ILogger { + constructor( + private readonly configProvider: IConfigurationProvider = defaultObservabilityConfigurationProvider + ) {} - return levels; + private getEnabledLogLevels(): Set { + return parseLogLevel(this.configProvider.getConfiguration().observabilityLogLevel); } info(message: string, ...args: unknown[]): void { - if (this.enabledLogLevels.has(1)) { + if (this.getEnabledLogLevels().has(LOG_LEVELS.info)) { console.log('[INFO]', message, ...args); } } warn(message: string, ...args: unknown[]): void { - if (this.enabledLogLevels.has(2)) { + if (this.getEnabledLogLevels().has(LOG_LEVELS.warn)) { console.warn('[WARN]', message, ...args); } } error(message: string, ...args: unknown[]): void { - if (this.enabledLogLevels.has(3)) { + if (this.getEnabledLogLevels().has(LOG_LEVELS.error)) { console.error('[ERROR]', message, ...args); } } @@ -176,12 +127,12 @@ let globalLogger: ILogger = new DefaultLogger(); /** * Set a custom logger implementation for the observability SDK - * + * * Example with Winston: * ```typescript * import * as winston from 'winston'; * import { setLogger } from '@microsoft/agents-a365-observability'; - * + * * const winstonLogger = winston.createLogger({ * level: 'info', * format: winston.format.json(), @@ -190,7 +141,7 @@ let globalLogger: ILogger = new DefaultLogger(); * new winston.transports.File({ filename: 'combined.log' }) * ] * }); - * + * * setLogger({ * info: (msg, ...args) => winstonLogger.info(msg, ...args), * warn: (msg, ...args) => winstonLogger.warn(msg, ...args), @@ -227,7 +178,8 @@ export function resetLogger(): void { } /** - * Default logger instance for backward compatibility + * Default logger instance for backward compatibility. + * Delegates to the global logger which can be replaced via setLogger(). */ export const logger: ILogger = { info: (message: string, ...args: unknown[]) => globalLogger.info(message, ...args), diff --git a/packages/agents-a365-runtime/docs/design.md b/packages/agents-a365-runtime/docs/design.md index 033d3056..d181225c 100644 --- a/packages/agents-a365-runtime/docs/design.md +++ b/packages/agents-a365-runtime/docs/design.md @@ -11,17 +11,18 @@ The runtime package provides foundational utilities shared across the Microsoft ``` ┌─────────────────────────────────────────────────────────────────┐ │ Public API │ -│ Utility | AgenticAuthenticationService | PowerPlatformApiDiscovery │ +│ Utility | AgenticAuthenticationService | RuntimeConfiguration │ +│ PowerPlatformApiDiscovery │ └─────────────────────────────────────────────────────────────────┘ │ ┌──────────────────┼──────────────────┐ ▼ ▼ ▼ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ -│ Utility │ │ Authentication │ │ API Discovery │ +│ Utility │ │ Configuration │ │ API Discovery │ │ │ │ │ │ │ -│ - Token decode │ │ - Token exchange │ │ - Endpoint URLs │ -│ - Agent identity │ │ - Scopes │ │ - Cluster config │ -│ - User-Agent │ │ │ │ │ +│ - Token decode │ │ - Cluster cat. │ │ - Endpoint URLs │ +│ - Agent identity │ │ - isDevEnv │ │ - Cluster config │ +│ - User-Agent │ │ - isNodeEnvDev │ │ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ ``` @@ -53,22 +54,6 @@ const userAgent = Utility.GetUserAgentHeader('MyOrchestrator'); | `ResolveAgentIdentity(context, authToken)` | Get agent identity from agentic request or token | | `GetUserAgentHeader(orchestrator?)` | Generate formatted User-Agent string | -### AgenticAuthenticationService ([agentic-authorization-service.ts](../src/agentic-authorization-service.ts)) - -Handles token exchange for MCP platform authentication: - -```typescript -import { AgenticAuthenticationService } from '@microsoft/agents-a365-runtime'; - -const token = await AgenticAuthenticationService.GetAgenticUserToken( - authorization, - authHandlerName, - turnContext -); -``` - -The service retrieves the MCP platform authentication scope from environment configuration and exchanges the user's token for a scoped access token. - ### PowerPlatformApiDiscovery ([power-platform-api-discovery.ts](../src/power-platform-api-discovery.ts)) Handles cluster-based endpoint resolution for Power Platform APIs: @@ -103,41 +88,96 @@ const islandEndpoint = discovery.getTenantIslandClusterEndpoint(tenantId); | `ex` | `api.powerplatform.eaglex.ic.gov` | | `rx` | `api.powerplatform.microsoft.scloud` | -### Environment Utilities ([environment-utils.ts](../src/environment-utils.ts)) +### AgenticAuthenticationService ([agentic-authorization-service.ts](../src/agentic-authorization-service.ts)) -Helper functions for environment-specific configuration: +Handles token exchange for platform authentication: + +```typescript +import { AgenticAuthenticationService } from '@microsoft/agents-a365-runtime'; + +// Get token with specified scopes +const token = await AgenticAuthenticationService.GetAgenticUserToken( + authorization, + authHandlerName, + turnContext, + ['scope1/.default', 'scope2/.default'] // Scopes to request +); +``` + +The service exchanges the user's token for a scoped access token. Callers should obtain the appropriate scopes from their domain-specific configuration (e.g., `ToolingConfiguration.mcpPlatformAuthenticationScope` for MCP platform authentication). + +### Configuration ([configuration/](../src/configuration/)) + +The runtime package provides a configuration system that supports programmatic overrides and environment variable fallbacks: ```typescript import { - getObservabilityAuthenticationScope, - getClusterCategory, - isDevelopmentEnvironment, - getMcpPlatformAuthenticationScope, + RuntimeConfiguration, + ClusterCategory, + defaultRuntimeConfigurationProvider, } from '@microsoft/agents-a365-runtime'; -// Get observability auth scopes (supports override via env var) -const scopes = getObservabilityAuthenticationScope(); -// => ["https://api.powerplatform.com/.default"] +// Using the default configuration provider (reads from env vars) +const config = defaultRuntimeConfigurationProvider.getConfiguration(); -// Get cluster category from CLUSTER_CATEGORY env var -const cluster = getClusterCategory(); -// => "prod" (default) +// Get cluster category +const cluster = config.clusterCategory; +// => "prod" (default, or from CLUSTER_CATEGORY env var) -// Check if running in development -const isDev = isDevelopmentEnvironment(); +// Check if running in development cluster +const isDev = config.isDevelopmentEnvironment; // => true if cluster is "local" or "dev" -// Get MCP platform auth scope -const mcpScope = getMcpPlatformAuthenticationScope(); +// Check if NODE_ENV is 'development' +const isNodeDev = config.isNodeEnvDevelopment; +// => true if NODE_ENV === 'development' +``` + +**Custom Configuration with Overrides:** + +```typescript +// Create configuration with programmatic overrides +const config = new RuntimeConfiguration({ + clusterCategory: () => ClusterCategory.gov, + isNodeEnvDevelopment: () => false, +}); + +// Dynamic per-tenant configuration +const tenantConfigs: Record = { + 'tenant-a': ClusterCategory.prod, + 'tenant-b': ClusterCategory.gov, +}; +let currentTenant = 'tenant-a'; + +const dynamicConfig = new RuntimeConfiguration({ + clusterCategory: () => tenantConfigs[currentTenant], +}); ``` **Environment Variables:** | Variable | Purpose | Default | |----------|---------|---------| -| `A365_OBSERVABILITY_SCOPES_OVERRIDE` | Override observability auth scopes | Production scope | | `CLUSTER_CATEGORY` | Environment cluster category | `prod` | -| `MCP_PLATFORM_AUTHENTICATION_SCOPE` | MCP platform auth scope | Production scope | +| `NODE_ENV` | Node.js environment (`development` enables local mode) | - | + +### Environment Utilities ([environment-utils.ts](../src/environment-utils.ts)) - Deprecated + +> **Note:** These functions are deprecated. Use the appropriate configuration class instead: +> - `RuntimeConfiguration` for `clusterCategory` and `isDevelopmentEnvironment` +> - `ToolingConfiguration` for `mcpPlatformAuthenticationScope` +> - `ObservabilityConfiguration` for `observabilityAuthenticationScopes` + +```typescript +import { + getObservabilityAuthenticationScope, // @deprecated - use ObservabilityConfiguration + getClusterCategory, // @deprecated - use RuntimeConfiguration + isDevelopmentEnvironment, // @deprecated - use RuntimeConfiguration + getMcpPlatformAuthenticationScope, // @deprecated - use ToolingConfiguration +} from '@microsoft/agents-a365-runtime'; +``` + +These functions are maintained only for backward compatibility. The `getClusterCategory` and `isDevelopmentEnvironment` functions delegate to `defaultRuntimeConfigurationProvider`, while the auth scope functions return hardcoded production defaults. ## Type Definitions @@ -170,17 +210,39 @@ The `Utility` class uses static methods for stateless operations, making it easy const appId = Utility.GetAppIdFromToken(token); ``` -### Environment-Based Configuration +### Configuration Provider Pattern -Configuration is environment-aware with sensible defaults: +Configuration is centralized in configuration classes with function-based overrides for dynamic resolution: ```typescript -export function getClusterCategory(): string { - const clusterCategory = process.env.CLUSTER_CATEGORY; - return clusterCategory?.toLowerCase() ?? 'prod'; +export class RuntimeConfiguration { + protected readonly overrides: RuntimeConfigurationOptions; + + constructor(overrides?: RuntimeConfigurationOptions) { + this.overrides = overrides ?? {}; + } + + get clusterCategory(): ClusterCategory { + // Override function called on each access (enables per-request resolution) + if (this.overrides.clusterCategory) { + return this.overrides.clusterCategory(); + } + // Fall back to environment variable + const envValue = process.env.CLUSTER_CATEGORY; + if (envValue) { + return envValue.toLowerCase() as ClusterCategory; + } + // Default value + return 'prod'; + } } ``` +This pattern enables: +- **Multi-tenant support**: Different values per request via async context +- **Testability**: Easy to override for testing without modifying env vars +- **Centralized access**: All env var reads in one place (enforced by ESLint) + ### Defensive Token Handling Token decoding handles edge cases gracefully: @@ -208,8 +270,16 @@ src/ ├── utility.ts # Utility class ├── agentic-authorization-service.ts # Token exchange service ├── power-platform-api-discovery.ts # Endpoint discovery -├── environment-utils.ts # Environment helpers -└── version.ts # Package version constant +├── environment-utils.ts # Environment helpers (deprecated) +├── version.ts # Package version constant +├── operation-error.ts # Operation error types +├── operation-result.ts # Operation result types +├── configuration/ +│ ├── index.ts # Configuration exports +│ ├── IConfigurationProvider.ts # Generic provider interface +│ ├── RuntimeConfiguration.ts # Base configuration class +│ ├── RuntimeConfigurationOptions.ts # Options type definition +│ └── DefaultConfigurationProvider.ts # Default provider implementation ``` ## Dependencies diff --git a/packages/agents-a365-runtime/src/agentic-authorization-service.ts b/packages/agents-a365-runtime/src/agentic-authorization-service.ts index 9c8f2431..509b1d8f 100644 --- a/packages/agents-a365-runtime/src/agentic-authorization-service.ts +++ b/packages/agents-a365-runtime/src/agentic-authorization-service.ts @@ -2,11 +2,52 @@ // Licensed under the MIT License. import { TurnContext, Authorization } from '@microsoft/agents-hosting'; -import { getMcpPlatformAuthenticationScope } from './environment-utils'; +import { PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE } from './environment-utils'; +/** + * Service for handling agentic user authentication. + */ export class AgenticAuthenticationService { - public static async GetAgenticUserToken(authorization: Authorization, authHandlerName: string, turnContext: TurnContext) { - const scope = getMcpPlatformAuthenticationScope(); - return (await authorization.exchangeToken(turnContext, authHandlerName, { scopes: [scope] })).token || ''; + /** + * Gets an agentic user token for platform authentication. + * Uses the default MCP platform authentication scope. + * + * @param authorization The authorization handler. + * @param authHandlerName The name of the auth handler to use. + * @param turnContext The turn context for the current request. + * @returns The token string, or empty string if no token was returned. + * @deprecated Use the overload with explicit scopes parameter for better control over requested permissions. + */ + public static async GetAgenticUserToken( + authorization: Authorization, + authHandlerName: string, + turnContext: TurnContext + ): Promise; + + /** + * Gets an agentic user token for platform authentication. + * + * @param authorization The authorization handler. + * @param authHandlerName The name of the auth handler to use. + * @param turnContext The turn context for the current request. + * @param scopes The OAuth scopes to request. Should be obtained from the appropriate configuration (e.g., ToolingConfiguration.mcpPlatformAuthenticationScope). + * @returns The token string, or empty string if no token was returned. + */ + public static async GetAgenticUserToken( + authorization: Authorization, + authHandlerName: string, + turnContext: TurnContext, + scopes: string[] + ): Promise; + + public static async GetAgenticUserToken( + authorization: Authorization, + authHandlerName: string, + turnContext: TurnContext, + scopes?: string[] + ): Promise { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- Intentional: maintaining backward compatibility for deprecated 3-param overload + const effectiveScopes = scopes ?? [PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE]; + return (await authorization.exchangeToken(turnContext, authHandlerName, { scopes: effectiveScopes })).token || ''; } } diff --git a/packages/agents-a365-runtime/src/configuration/DefaultConfigurationProvider.ts b/packages/agents-a365-runtime/src/configuration/DefaultConfigurationProvider.ts new file mode 100644 index 00000000..8bc7e7c3 --- /dev/null +++ b/packages/agents-a365-runtime/src/configuration/DefaultConfigurationProvider.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { IConfigurationProvider } from './IConfigurationProvider'; +import { RuntimeConfiguration } from './RuntimeConfiguration'; + +/** + * Default provider that returns environment-based configuration. + * + * **Multi-tenant considerations:** + * This provider creates a single configuration instance at construction time, + * shared across all requests in a process. The default module-level providers + * (e.g., `defaultRuntimeConfigurationProvider`) are singletons. + * + * For multi-tenant scenarios, two approaches are supported: + * + * 1. **Dynamic override functions (recommended):** Pass override functions that + * read from async context (e.g., OpenTelemetry baggage) at runtime. The same + * Configuration instance returns different values per request. + * ```typescript + * const config = new ToolingConfiguration({ + * mcpPlatformEndpoint: () => { + * const tenantConfig = context.active().getValue(TENANT_KEY); + * return tenantConfig?.endpoint ?? 'https://default.endpoint'; + * } + * }); + * ``` + * + * 2. **Per-tenant providers:** Create separate provider instances for each tenant + * when different tenants need different override functions entirely. + */ +export class DefaultConfigurationProvider + implements IConfigurationProvider { + + private readonly _configuration: T; + + constructor(factory: () => T) { + this._configuration = factory(); + } + + getConfiguration(): T { + return this._configuration; + } +} + +/** + * Shared default provider for RuntimeConfiguration. + * Uses environment variables with no overrides - suitable for single-tenant + * deployments or when using dynamic override functions for multi-tenancy. + */ +export const defaultRuntimeConfigurationProvider = + new DefaultConfigurationProvider(() => new RuntimeConfiguration()); diff --git a/packages/agents-a365-runtime/src/configuration/IConfigurationProvider.ts b/packages/agents-a365-runtime/src/configuration/IConfigurationProvider.ts new file mode 100644 index 00000000..78a5707c --- /dev/null +++ b/packages/agents-a365-runtime/src/configuration/IConfigurationProvider.ts @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Generic interface for providing configuration. + * Each package defines its own configuration type T. + */ +export interface IConfigurationProvider { + getConfiguration(): T; +} diff --git a/packages/agents-a365-runtime/src/configuration/RuntimeConfiguration.ts b/packages/agents-a365-runtime/src/configuration/RuntimeConfiguration.ts new file mode 100644 index 00000000..54f631d5 --- /dev/null +++ b/packages/agents-a365-runtime/src/configuration/RuntimeConfiguration.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ClusterCategory } from '../power-platform-api-discovery'; +import { RuntimeConfigurationOptions } from './RuntimeConfigurationOptions'; + +/** + * Base configuration class for Agent365 SDK. + * Other packages extend this to add their own settings. + * + * Override functions are called on each property access, enabling dynamic + * resolution from async context (e.g., OpenTelemetry baggage) per-request. + */ +export class RuntimeConfiguration { + protected readonly overrides: RuntimeConfigurationOptions; + + /** + * Parse an environment variable as a boolean. + * Recognizes 'true', '1', 'yes', 'on' (case-insensitive) as true; all other values as false. + */ + public static parseEnvBoolean(envValue: string | undefined): boolean { + if (!envValue) return false; + return ['true', '1', 'yes', 'on'].includes(envValue.toLowerCase()); + } + + /** + * Parse an environment variable as an integer, returning fallback if invalid or not set. + */ + public static parseEnvInt(envValue: string | undefined, fallback: number): number { + if (!envValue) return fallback; + const parsed = parseInt(envValue, 10); + return Number.isFinite(parsed) ? parsed : fallback; + } + + constructor(overrides?: RuntimeConfigurationOptions) { + this.overrides = overrides ?? {}; + } + + get clusterCategory(): ClusterCategory { + if (this.overrides.clusterCategory) { + return this.overrides.clusterCategory(); + } + const envValue = process.env.CLUSTER_CATEGORY; + if (envValue) { + const normalized = envValue.toLowerCase(); + if (Object.values(ClusterCategory).includes(normalized as ClusterCategory)) { + return normalized as ClusterCategory; + } + // Invalid value - fall through to default + } + return ClusterCategory.prod; + } + + /** + * Whether the cluster is a development environment (local or dev). + * Based on clusterCategory. + */ + get isDevelopmentEnvironment(): boolean { + return [ClusterCategory.local, ClusterCategory.dev].includes(this.clusterCategory); + } + + /** + * Whether NODE_ENV indicates development mode. + * Returns true when NODE_ENV is 'development' (case-insensitive). + * This is the standard Node.js way of indicating development mode. + */ + get isNodeEnvDevelopment(): boolean { + const override = this.overrides.isNodeEnvDevelopment?.(); + if (override !== undefined) return override; + + const nodeEnv = process.env.NODE_ENV ?? ''; + return nodeEnv.toLowerCase() === 'development'; + } +} diff --git a/packages/agents-a365-runtime/src/configuration/RuntimeConfigurationOptions.ts b/packages/agents-a365-runtime/src/configuration/RuntimeConfigurationOptions.ts new file mode 100644 index 00000000..de36edb7 --- /dev/null +++ b/packages/agents-a365-runtime/src/configuration/RuntimeConfigurationOptions.ts @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ClusterCategory } from '../power-platform-api-discovery'; + +/** + * Runtime configuration options - all optional functions. + * Functions are called on each property access, enabling dynamic resolution. + * Unset values fall back to environment variables. + */ +export type RuntimeConfigurationOptions = { + /** + * Override function for cluster category. + * Called on each property access to enable dynamic per-request resolution. + * Falls back to CLUSTER_CATEGORY env var, then 'prod'. + * + * @example + * // Static override + * { clusterCategory: () => ClusterCategory.gov } + * + * // Dynamic per-tenant override using async context + * { clusterCategory: () => context.active().getValue(TENANT_CONFIG_KEY)?.cluster ?? ClusterCategory.prod } + */ + clusterCategory?: () => ClusterCategory; + /** + * Override for NODE_ENV-based development mode detection. + * Called on each property access to enable dynamic per-request resolution. + * Falls back to NODE_ENV === 'development' check. + * + * @example + * // Static override + * { isNodeEnvDevelopment: () => true } + * + * // Dynamic override based on request context + * { isNodeEnvDevelopment: () => context.active().getValue(DEBUG_KEY) === true } + */ + isNodeEnvDevelopment?: () => boolean; +}; diff --git a/packages/agents-a365-runtime/src/configuration/index.ts b/packages/agents-a365-runtime/src/configuration/index.ts new file mode 100644 index 00000000..ff77a4f1 --- /dev/null +++ b/packages/agents-a365-runtime/src/configuration/index.ts @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './IConfigurationProvider'; +export * from './RuntimeConfigurationOptions'; +export * from './RuntimeConfiguration'; +export * from './DefaultConfigurationProvider'; diff --git a/packages/agents-a365-runtime/src/environment-utils.ts b/packages/agents-a365-runtime/src/environment-utils.ts index 25e4a6f0..77aedb78 100644 --- a/packages/agents-a365-runtime/src/environment-utils.ts +++ b/packages/agents-a365-runtime/src/environment-utils.ts @@ -1,13 +1,38 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. /** * Utility logic for environment-related operations. + * + * Note: These utility functions are maintained for backward compatibility. + * For new code, prefer using the configuration classes directly: + * - RuntimeConfiguration for clusterCategory, isDevelopmentEnvironment, isNodeEnvDevelopment + * - ToolingConfiguration for mcpPlatformAuthenticationScope + * - ObservabilityConfiguration for observabilityAuthenticationScopes */ +import { RuntimeConfiguration, defaultRuntimeConfigurationProvider } from './configuration'; +import { IConfigurationProvider } from './configuration/IConfigurationProvider'; + +/** + * Production observability authentication scope. + * @deprecated This constant is exported for backward compatibility only. + * For new code, use `ObservabilityConfiguration.observabilityAuthenticationScopes` instead. + */ export const PROD_OBSERVABILITY_SCOPE = 'https://api.powerplatform.com/.default'; + +/** + * Production MCP platform authentication scope. + * @deprecated This constant is exported for backward compatibility only. + * For new code, use `ToolingConfiguration.mcpPlatformAuthenticationScope` instead. + */ export const PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE = 'ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default'; + +/** + * Default cluster category for production environments. + * @deprecated This constant is exported for backward compatibility only. + * For new code, use `RuntimeConfiguration.clusterCategory` instead. + */ export const PROD_OBSERVABILITY_CLUSTER_CATEGORY = 'prod'; // Default environment names @@ -15,57 +40,89 @@ export const PRODUCTION_ENVIRONMENT_NAME = 'production'; export const DEVELOPMENT_ENVIRONMENT_NAME = 'Development'; /** - * Returns the scope for authenticating to the observability service + * Returns the scope for authenticating to the observability service. * - * The default is the production observability scope, but this can be overridden - * for internal development and testing scenarios using the - * `A365_OBSERVABILITY_SCOPES_OVERRIDE` environment variable. + * @returns The authentication scopes for the current environment. + * @deprecated Use ObservabilityConfiguration.observabilityAuthenticationScopes instead. * - * When the override is set to a non-empty string, it is split on whitespace - * into individual scopes. + * @example + * // Before: + * import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runtime'; + * const scopes = getObservabilityAuthenticationScope(); * - * @returns The authentication scopes for the current environment. + * // After: + * import { defaultObservabilityConfigurationProvider } from '@microsoft/agents-a365-observability'; + * const scopes = [...defaultObservabilityConfigurationProvider.getConfiguration().observabilityAuthenticationScopes]; */ export function getObservabilityAuthenticationScope(): string[] { - const override = process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE; - - if (override && override.trim().length > 0) { - return override.trim().split(/\s+/); - } - + // Returns production default - use ObservabilityConfiguration for proper env var support + // eslint-disable-next-line @typescript-eslint/no-deprecated -- Intentional: deprecated function using deprecated constant return [PROD_OBSERVABILITY_SCOPE]; } /** * Gets the cluster category from environment variables. * + * @param configProvider Optional configuration provider. Defaults to defaultRuntimeConfigurationProvider if not specified. * @returns The cluster category from CLUSTER_CATEGORY env var, defaults to 'prod'. + * @deprecated Use RuntimeConfiguration.clusterCategory instead. + * + * @example + * // Before: + * import { getClusterCategory } from '@microsoft/agents-a365-runtime'; + * const cluster = getClusterCategory(); + * + * // After: + * import { defaultRuntimeConfigurationProvider } from '@microsoft/agents-a365-runtime'; + * const cluster = defaultRuntimeConfigurationProvider.getConfiguration().clusterCategory; */ -export function getClusterCategory(): string { - const clusterCategory = process.env.CLUSTER_CATEGORY; - - if (!clusterCategory) { - return 'prod'; - } - - return clusterCategory.toLowerCase(); +export function getClusterCategory( + configProvider?: IConfigurationProvider +): string { + const provider = configProvider ?? defaultRuntimeConfigurationProvider; + return provider.getConfiguration().clusterCategory; } /** * Returns true if the current environment is a development environment. * + * @param configProvider Optional configuration provider. Defaults to defaultRuntimeConfigurationProvider if not specified. * @returns True if the current environment is development, false otherwise. + * @deprecated Use RuntimeConfiguration.isDevelopmentEnvironment instead. + * + * @example + * // Before: + * import { isDevelopmentEnvironment } from '@microsoft/agents-a365-runtime'; + * if (isDevelopmentEnvironment()) { ... } + * + * // After: + * import { defaultRuntimeConfigurationProvider } from '@microsoft/agents-a365-runtime'; + * if (defaultRuntimeConfigurationProvider.getConfiguration().isDevelopmentEnvironment) { ... } */ -export function isDevelopmentEnvironment(): boolean { - const clusterCategory = getClusterCategory(); - return ['local', 'dev'].includes(clusterCategory); +export function isDevelopmentEnvironment( + configProvider?: IConfigurationProvider +): boolean { + const provider = configProvider ?? defaultRuntimeConfigurationProvider; + return provider.getConfiguration().isDevelopmentEnvironment; } /** - * Gets the MCP platform authentication scope from environment variables. + * Gets the MCP platform authentication scope. + * + * @returns The MCP platform authentication scope. + * @deprecated Use ToolingConfiguration.mcpPlatformAuthenticationScope instead. + * + * @example + * // Before: + * import { getMcpPlatformAuthenticationScope } from '@microsoft/agents-a365-runtime'; + * const scope = getMcpPlatformAuthenticationScope(); * - * @returns The MCP platform authentication scope from MCP_PLATFORM_AUTHENTICATION_SCOPE env var, defaults to production scope. + * // After: + * import { defaultToolingConfigurationProvider } from '@microsoft/agents-a365-tooling'; + * const scope = defaultToolingConfigurationProvider.getConfiguration().mcpPlatformAuthenticationScope; */ export function getMcpPlatformAuthenticationScope(): string { - return process.env.MCP_PLATFORM_AUTHENTICATION_SCOPE || PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE; + // Returns production default - use ToolingConfiguration for proper env var support + // eslint-disable-next-line @typescript-eslint/no-deprecated -- Intentional: deprecated function using deprecated constant + return PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE; } diff --git a/packages/agents-a365-runtime/src/index.ts b/packages/agents-a365-runtime/src/index.ts index e44719d7..2eb2509a 100644 --- a/packages/agents-a365-runtime/src/index.ts +++ b/packages/agents-a365-runtime/src/index.ts @@ -1,6 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + export * from './power-platform-api-discovery'; -export * from './agentic-authorization-service'; export * from './environment-utils'; export * from './utility'; +export * from './agentic-authorization-service'; export * from './operation-error'; -export * from './operation-result'; \ No newline at end of file +export * from './operation-result'; +export * from './configuration'; diff --git a/packages/agents-a365-runtime/src/power-platform-api-discovery.ts b/packages/agents-a365-runtime/src/power-platform-api-discovery.ts index 930e3864..ba1b0f9c 100644 --- a/packages/agents-a365-runtime/src/power-platform-api-discovery.ts +++ b/packages/agents-a365-runtime/src/power-platform-api-discovery.ts @@ -1,16 +1,21 @@ -export type ClusterCategory = - | 'local' - | 'dev' - | 'test' - | 'preprod' - | 'firstrelease' - | 'prod' - | 'gov' - | 'high' - | 'dod' - | 'mooncake' - | 'ex' - | 'rx'; +/** + * Cluster categories for Power Platform API discovery. + * String enum provides both compile-time type safety and runtime validation. + */ +export enum ClusterCategory { + local = 'local', + dev = 'dev', + test = 'test', + preprod = 'preprod', + firstrelease = 'firstrelease', + prod = 'prod', + gov = 'gov', + high = 'high', + dod = 'dod', + mooncake = 'mooncake', + ex = 'ex', + rx = 'rx', +} export class PowerPlatformApiDiscovery { readonly clusterCategory: ClusterCategory; @@ -71,8 +76,8 @@ export class PowerPlatformApiDiscovery { private _getHexApiSuffixLength(): number { switch (this.clusterCategory) { - case 'firstrelease': - case 'prod': + case ClusterCategory.firstrelease: + case ClusterCategory.prod: return 2; default: return 1; diff --git a/packages/agents-a365-runtime/src/utility.ts b/packages/agents-a365-runtime/src/utility.ts index 9cae7dea..5fea577c 100644 --- a/packages/agents-a365-runtime/src/utility.ts +++ b/packages/agents-a365-runtime/src/utility.ts @@ -139,7 +139,9 @@ export class Utility { */ public static getApplicationName(): string | undefined { // First try npm_package_name (set automatically by npm/pnpm when running scripts) + // eslint-disable-next-line no-restricted-properties -- npm_package_name is set by npm at runtime, not a configurable setting if (process.env.npm_package_name) { + // eslint-disable-next-line no-restricted-properties -- npm_package_name is set by npm at runtime, not a configurable setting return process.env.npm_package_name; } diff --git a/packages/agents-a365-tooling-extensions-claude/src/McpToolRegistrationService.ts b/packages/agents-a365-tooling-extensions-claude/src/McpToolRegistrationService.ts index 35a19524..fa138b60 100644 --- a/packages/agents-a365-tooling-extensions-claude/src/McpToolRegistrationService.ts +++ b/packages/agents-a365-tooling-extensions-claude/src/McpToolRegistrationService.ts @@ -2,7 +2,8 @@ // Licensed under the MIT License. import { McpToolServerConfigurationService, McpClientTool, Utility, MCPServerConfig, ToolOptions } from '@microsoft/agents-a365-tooling'; -import { AgenticAuthenticationService, Utility as RuntimeUtility } from '@microsoft/agents-a365-runtime'; +import { AgenticAuthenticationService, IConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { ClaudeToolingConfiguration, defaultClaudeToolingConfigurationProvider } from './configuration'; // Agents SDK import { TurnContext, Authorization } from '@microsoft/agents-hosting'; @@ -15,9 +16,19 @@ import type { McpServerConfig, Options } from '@anthropic-ai/claude-agent-sdk'; * Use getMcpServers to fetch server configs and getTools to enumerate tools. */ export class McpToolRegistrationService { - private readonly configService: McpToolServerConfigurationService = new McpToolServerConfigurationService(); + private readonly configService: McpToolServerConfigurationService; + private readonly configProvider: IConfigurationProvider; private readonly orchestratorName: string = "Claude"; + /** + * Construct a McpToolRegistrationService. + * @param configProvider Optional configuration provider. Defaults to defaultClaudeToolingConfigurationProvider if not specified. + */ + constructor(configProvider?: IConfigurationProvider) { + this.configProvider = configProvider ?? defaultClaudeToolingConfigurationProvider; + this.configService = new McpToolServerConfigurationService(this.configProvider); + } + /** * Registers MCP tool servers and updates agent options with discovered tools and server configs. * Call this to enable dynamic Claude tool access. @@ -40,15 +51,15 @@ export class McpToolRegistrationService { } if (!authToken) { - authToken = await AgenticAuthenticationService.GetAgenticUserToken(authorization, authHandlerName, turnContext); + const scope = this.configProvider.getConfiguration().mcpPlatformAuthenticationScope; + authToken = await AgenticAuthenticationService.GetAgenticUserToken(authorization, authHandlerName, turnContext, [scope]); } // Validate the authentication token Utility.ValidateAuthToken(authToken); - const agenticAppId = RuntimeUtility.ResolveAgentIdentity(turnContext, authToken); const options: ToolOptions = { orchestratorName: this.orchestratorName }; - const servers = await this.configService.listToolServers(agenticAppId, authToken, options); + const servers = await this.configService.listToolServers(turnContext, authorization, authHandlerName, authToken, options); const mcpServers: Record = {}; const tools: McpClientTool[] = []; diff --git a/packages/agents-a365-tooling-extensions-claude/src/configuration/ClaudeToolingConfiguration.ts b/packages/agents-a365-tooling-extensions-claude/src/configuration/ClaudeToolingConfiguration.ts new file mode 100644 index 00000000..493228ee --- /dev/null +++ b/packages/agents-a365-tooling-extensions-claude/src/configuration/ClaudeToolingConfiguration.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ToolingConfiguration } from '@microsoft/agents-a365-tooling'; +import { ClaudeToolingConfigurationOptions } from './ClaudeToolingConfigurationOptions'; + +/** + * Configuration for Claude tooling extension package. + * Inherits all tooling and runtime settings. + * + * ## Why This Class Exists + * + * Although this class currently adds no new settings beyond what ToolingConfiguration + * provides, it exists for several important reasons: + * + * 1. **Type Safety**: Allows Claude-specific services to declare their dependency on + * `IConfigurationProvider`, making the configuration + * contract explicit and enabling compile-time checking. + * + * 2. **Extension Point**: Provides a clear place to add Claude-specific settings + * (e.g., Claude API timeouts, model preferences, retry policies) without breaking + * existing code when those needs arise. + * + * 3. **Consistent Pattern**: Maintains symmetry with other extension packages + * (LangChain, OpenAI), making the SDK easier to understand and navigate. + * + * 4. **Dependency Injection**: Services can be designed to accept this specific + * configuration type, enabling proper IoC patterns and testability. + * + * @example + * ```typescript + * // Service declares explicit dependency on Claude configuration + * class ClaudeService { + * constructor(private configProvider: IConfigurationProvider) {} + * } + * + * // Future: Add Claude-specific settings without breaking changes + * class ClaudeToolingConfiguration extends ToolingConfiguration { + * get claudeApiTimeout(): number { ... } + * } + * ``` + */ +export class ClaudeToolingConfiguration extends ToolingConfiguration { + constructor(overrides?: ClaudeToolingConfigurationOptions) { + super(overrides); + } + + // Inherited: clusterCategory, isDevelopmentEnvironment, mcpPlatformEndpoint, mcpPlatformAuthenticationScope +} diff --git a/packages/agents-a365-tooling-extensions-claude/src/configuration/ClaudeToolingConfigurationOptions.ts b/packages/agents-a365-tooling-extensions-claude/src/configuration/ClaudeToolingConfigurationOptions.ts new file mode 100644 index 00000000..586abdb1 --- /dev/null +++ b/packages/agents-a365-tooling-extensions-claude/src/configuration/ClaudeToolingConfigurationOptions.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ToolingConfigurationOptions } from '@microsoft/agents-a365-tooling'; + +/** + * Claude tooling configuration options - extends tooling options. + * All overrides are functions called on each property access. + * + * Currently no additional settings; this type exists for future extensibility. + */ +export type ClaudeToolingConfigurationOptions = ToolingConfigurationOptions; diff --git a/packages/agents-a365-tooling-extensions-claude/src/configuration/index.ts b/packages/agents-a365-tooling-extensions-claude/src/configuration/index.ts new file mode 100644 index 00000000..df4f836c --- /dev/null +++ b/packages/agents-a365-tooling-extensions-claude/src/configuration/index.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { ClaudeToolingConfiguration } from './ClaudeToolingConfiguration'; + +export * from './ClaudeToolingConfigurationOptions'; +export * from './ClaudeToolingConfiguration'; + +/** + * Shared default provider for ClaudeToolingConfiguration. + */ +export const defaultClaudeToolingConfigurationProvider = + new DefaultConfigurationProvider(() => new ClaudeToolingConfiguration()); diff --git a/packages/agents-a365-tooling-extensions-claude/src/index.ts b/packages/agents-a365-tooling-extensions-claude/src/index.ts index f9134a94..2038ad49 100644 --- a/packages/agents-a365-tooling-extensions-claude/src/index.ts +++ b/packages/agents-a365-tooling-extensions-claude/src/index.ts @@ -1 +1,5 @@ -export * from './McpToolRegistrationService'; \ No newline at end of file +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './McpToolRegistrationService'; +export * from './configuration'; diff --git a/packages/agents-a365-tooling-extensions-langchain/src/McpToolRegistrationService.ts b/packages/agents-a365-tooling-extensions-langchain/src/McpToolRegistrationService.ts index 65ef1b49..615d2e8f 100644 --- a/packages/agents-a365-tooling-extensions-langchain/src/McpToolRegistrationService.ts +++ b/packages/agents-a365-tooling-extensions-langchain/src/McpToolRegistrationService.ts @@ -3,7 +3,8 @@ // Microsoft Agent 365 SDK import { McpToolServerConfigurationService, Utility, ToolOptions, ChatHistoryMessage } from '@microsoft/agents-a365-tooling'; -import { AgenticAuthenticationService, Utility as RuntimeUtility, OperationResult, OperationError } from '@microsoft/agents-a365-runtime'; +import { AgenticAuthenticationService, OperationResult, OperationError, IConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { LangChainToolingConfiguration, defaultLangChainToolingConfigurationProvider } from './configuration'; // Agents SDK import { TurnContext, Authorization } from '@microsoft/agents-hosting'; @@ -30,9 +31,19 @@ import type { CompiledStateGraph, StateSnapshot } from '@langchain/langgraph'; * real-time threat protection (RTP) analysis. */ export class McpToolRegistrationService { - private configService: McpToolServerConfigurationService = new McpToolServerConfigurationService(); + private readonly configService: McpToolServerConfigurationService; + private readonly configProvider: IConfigurationProvider; private readonly orchestratorName: string = "LangChain"; + /** + * Construct a McpToolRegistrationService. + * @param configProvider Optional configuration provider. Defaults to defaultLangChainToolingConfigurationProvider if not specified. + */ + constructor(configProvider?: IConfigurationProvider) { + this.configProvider = configProvider ?? defaultLangChainToolingConfigurationProvider; + this.configService = new McpToolServerConfigurationService(this.configProvider); + } + /** * Registers MCP tool servers and updates agent options with discovered tools and server configs. * Call this to enable dynamic LangChain tool access based on the current MCP environment. @@ -56,15 +67,15 @@ export class McpToolRegistrationService { } if (!authToken) { - authToken = await AgenticAuthenticationService.GetAgenticUserToken(authorization, authHandlerName, turnContext); + const scope = this.configProvider.getConfiguration().mcpPlatformAuthenticationScope; + authToken = await AgenticAuthenticationService.GetAgenticUserToken(authorization, authHandlerName, turnContext, [scope]); } // Validate the authentication token Utility.ValidateAuthToken(authToken); - const agenticAppId = RuntimeUtility.ResolveAgentIdentity(turnContext, authToken); const options: ToolOptions = { orchestratorName: this.orchestratorName }; - const servers = await this.configService.listToolServers(agenticAppId, authToken, options); + const servers = await this.configService.listToolServers(turnContext, authorization, authHandlerName, authToken, options); const mcpServers: Record = {}; for (const server of servers) { @@ -360,6 +371,7 @@ export class McpToolRegistrationService { * @returns The mapped role string. */ private mapRole(message: BaseMessage): string { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- LangChain API deprecation, not our code const type = message.getType(); switch (type) { diff --git a/packages/agents-a365-tooling-extensions-langchain/src/configuration/LangChainToolingConfiguration.ts b/packages/agents-a365-tooling-extensions-langchain/src/configuration/LangChainToolingConfiguration.ts new file mode 100644 index 00000000..1b176e3d --- /dev/null +++ b/packages/agents-a365-tooling-extensions-langchain/src/configuration/LangChainToolingConfiguration.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ToolingConfiguration } from '@microsoft/agents-a365-tooling'; +import { LangChainToolingConfigurationOptions } from './LangChainToolingConfigurationOptions'; + +/** + * Configuration for LangChain tooling extension package. + * Inherits all tooling and runtime settings. + * + * ## Why This Class Exists + * + * Although this class currently adds no new settings beyond what ToolingConfiguration + * provides, it exists for several important reasons: + * + * 1. **Type Safety**: Allows LangChain-specific services to declare their dependency on + * `IConfigurationProvider`, making the configuration + * contract explicit and enabling compile-time checking. + * + * 2. **Extension Point**: Provides a clear place to add LangChain-specific settings + * (e.g., graph execution timeouts, checkpoint intervals, memory limits) without + * breaking existing code when those needs arise. + * + * 3. **Consistent Pattern**: Maintains symmetry with other extension packages + * (Claude, OpenAI), making the SDK easier to understand and navigate. + * + * 4. **Dependency Injection**: Services can be designed to accept this specific + * configuration type, enabling proper IoC patterns and testability. + * + * @example + * ```typescript + * // Service declares explicit dependency on LangChain configuration + * class LangChainService { + * constructor(private configProvider: IConfigurationProvider) {} + * } + * + * // Future: Add LangChain-specific settings without breaking changes + * class LangChainToolingConfiguration extends ToolingConfiguration { + * get graphExecutionTimeout(): number { ... } + * } + * ``` + */ +export class LangChainToolingConfiguration extends ToolingConfiguration { + constructor(overrides?: LangChainToolingConfigurationOptions) { + super(overrides); + } + + // Inherited: clusterCategory, isDevelopmentEnvironment, mcpPlatformEndpoint, mcpPlatformAuthenticationScope +} diff --git a/packages/agents-a365-tooling-extensions-langchain/src/configuration/LangChainToolingConfigurationOptions.ts b/packages/agents-a365-tooling-extensions-langchain/src/configuration/LangChainToolingConfigurationOptions.ts new file mode 100644 index 00000000..ea2b1ad1 --- /dev/null +++ b/packages/agents-a365-tooling-extensions-langchain/src/configuration/LangChainToolingConfigurationOptions.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ToolingConfigurationOptions } from '@microsoft/agents-a365-tooling'; + +/** + * LangChain tooling configuration options - extends tooling options. + * All overrides are functions called on each property access. + * + * Currently no additional settings; this type exists for future extensibility. + */ +export type LangChainToolingConfigurationOptions = ToolingConfigurationOptions; diff --git a/packages/agents-a365-tooling-extensions-langchain/src/configuration/index.ts b/packages/agents-a365-tooling-extensions-langchain/src/configuration/index.ts new file mode 100644 index 00000000..8265b1f8 --- /dev/null +++ b/packages/agents-a365-tooling-extensions-langchain/src/configuration/index.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { LangChainToolingConfiguration } from './LangChainToolingConfiguration'; + +export * from './LangChainToolingConfigurationOptions'; +export * from './LangChainToolingConfiguration'; + +/** + * Shared default provider for LangChainToolingConfiguration. + */ +export const defaultLangChainToolingConfigurationProvider = + new DefaultConfigurationProvider(() => new LangChainToolingConfiguration()); diff --git a/packages/agents-a365-tooling-extensions-langchain/src/index.ts b/packages/agents-a365-tooling-extensions-langchain/src/index.ts index f9b8eed8..2038ad49 100644 --- a/packages/agents-a365-tooling-extensions-langchain/src/index.ts +++ b/packages/agents-a365-tooling-extensions-langchain/src/index.ts @@ -2,3 +2,4 @@ // Licensed under the MIT License. export * from './McpToolRegistrationService'; +export * from './configuration'; diff --git a/packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts b/packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts index 40624fd4..890f44d8 100644 --- a/packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts +++ b/packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts @@ -3,7 +3,8 @@ import { v4 as uuidv4 } from 'uuid'; import { McpToolServerConfigurationService, Utility, ToolOptions, ChatHistoryMessage } from '@microsoft/agents-a365-tooling'; -import { AgenticAuthenticationService, Utility as RuntimeUtility, OperationResult, OperationError } from '@microsoft/agents-a365-runtime'; +import { AgenticAuthenticationService, OperationResult, OperationError, IConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { OpenAIToolingConfiguration, defaultOpenAIToolingConfigurationProvider } from './configuration'; // Agents SDK import { TurnContext, Authorization } from '@microsoft/agents-hosting'; @@ -17,9 +18,19 @@ import { OpenAIConversationsSession } from '@openai/agents-openai'; * Uses listToolServers to fetch server configs. */ export class McpToolRegistrationService { - private configService: McpToolServerConfigurationService = new McpToolServerConfigurationService(); + private readonly configService: McpToolServerConfigurationService; + private readonly configProvider: IConfigurationProvider; private readonly orchestratorName: string = "OpenAI"; + /** + * Construct a McpToolRegistrationService. + * @param configProvider Optional configuration provider. Defaults to defaultOpenAIToolingConfigurationProvider if not specified. + */ + constructor(configProvider?: IConfigurationProvider) { + this.configProvider = configProvider ?? defaultOpenAIToolingConfigurationProvider; + this.configService = new McpToolServerConfigurationService(this.configProvider); + } + /** * Registers MCP tool servers and updates agent options with discovered tools and server configs. @@ -44,15 +55,15 @@ export class McpToolRegistrationService { } if (!authToken) { - authToken = await AgenticAuthenticationService.GetAgenticUserToken(authorization, authHandlerName, turnContext); + const scope = this.configProvider.getConfiguration().mcpPlatformAuthenticationScope; + authToken = await AgenticAuthenticationService.GetAgenticUserToken(authorization, authHandlerName, turnContext, [scope]); } // Validate the authentication token Utility.ValidateAuthToken(authToken); - const agenticAppId = RuntimeUtility.ResolveAgentIdentity(turnContext, authToken); const options: ToolOptions = { orchestratorName: this.orchestratorName }; - const servers = await this.configService.listToolServers(agenticAppId, authToken, options); + const servers = await this.configService.listToolServers(turnContext, authorization, authHandlerName, authToken, options); const mcpServers: MCPServerStreamableHttp[] = []; for (const server of servers) { diff --git a/packages/agents-a365-tooling-extensions-openai/src/configuration/OpenAIToolingConfiguration.ts b/packages/agents-a365-tooling-extensions-openai/src/configuration/OpenAIToolingConfiguration.ts new file mode 100644 index 00000000..f5c22b35 --- /dev/null +++ b/packages/agents-a365-tooling-extensions-openai/src/configuration/OpenAIToolingConfiguration.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ToolingConfiguration } from '@microsoft/agents-a365-tooling'; +import { OpenAIToolingConfigurationOptions } from './OpenAIToolingConfigurationOptions'; + +/** + * Configuration for OpenAI tooling extension package. + * Inherits all tooling and runtime settings. + * + * ## Why This Class Exists + * + * Although this class currently adds no new settings beyond what ToolingConfiguration + * provides, it exists for several important reasons: + * + * 1. **Type Safety**: Allows OpenAI-specific services to declare their dependency on + * `IConfigurationProvider`, making the configuration + * contract explicit and enabling compile-time checking. + * + * 2. **Extension Point**: Provides a clear place to add OpenAI-specific settings + * (e.g., Agents SDK timeouts, thread polling intervals, run limits) without + * breaking existing code when those needs arise. + * + * 3. **Consistent Pattern**: Maintains symmetry with other extension packages + * (Claude, LangChain), making the SDK easier to understand and navigate. + * + * 4. **Dependency Injection**: Services can be designed to accept this specific + * configuration type, enabling proper IoC patterns and testability. + * + * @example + * ```typescript + * // Service declares explicit dependency on OpenAI configuration + * class OpenAIService { + * constructor(private configProvider: IConfigurationProvider) {} + * } + * + * // Future: Add OpenAI-specific settings without breaking changes + * class OpenAIToolingConfiguration extends ToolingConfiguration { + * get threadPollingInterval(): number { ... } + * } + * ``` + */ +export class OpenAIToolingConfiguration extends ToolingConfiguration { + constructor(overrides?: OpenAIToolingConfigurationOptions) { + super(overrides); + } + + // Inherited: clusterCategory, isDevelopmentEnvironment, mcpPlatformEndpoint, mcpPlatformAuthenticationScope +} diff --git a/packages/agents-a365-tooling-extensions-openai/src/configuration/OpenAIToolingConfigurationOptions.ts b/packages/agents-a365-tooling-extensions-openai/src/configuration/OpenAIToolingConfigurationOptions.ts new file mode 100644 index 00000000..d4c4c36e --- /dev/null +++ b/packages/agents-a365-tooling-extensions-openai/src/configuration/OpenAIToolingConfigurationOptions.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ToolingConfigurationOptions } from '@microsoft/agents-a365-tooling'; + +/** + * OpenAI tooling configuration options - extends tooling options. + * All overrides are functions called on each property access. + * + * Currently no additional settings; this type exists for future extensibility. + */ +export type OpenAIToolingConfigurationOptions = ToolingConfigurationOptions; diff --git a/packages/agents-a365-tooling-extensions-openai/src/configuration/index.ts b/packages/agents-a365-tooling-extensions-openai/src/configuration/index.ts new file mode 100644 index 00000000..42a19c65 --- /dev/null +++ b/packages/agents-a365-tooling-extensions-openai/src/configuration/index.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { OpenAIToolingConfiguration } from './OpenAIToolingConfiguration'; + +export * from './OpenAIToolingConfigurationOptions'; +export * from './OpenAIToolingConfiguration'; + +/** + * Shared default provider for OpenAIToolingConfiguration. + */ +export const defaultOpenAIToolingConfigurationProvider = + new DefaultConfigurationProvider(() => new OpenAIToolingConfiguration()); diff --git a/packages/agents-a365-tooling-extensions-openai/src/index.ts b/packages/agents-a365-tooling-extensions-openai/src/index.ts index 5dda0fef..04dee01f 100644 --- a/packages/agents-a365-tooling-extensions-openai/src/index.ts +++ b/packages/agents-a365-tooling-extensions-openai/src/index.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. export * from './McpToolRegistrationService'; +export * from './configuration'; // Re-export OpenAI types for convenience -export { OpenAIConversationsSession } from '@openai/agents-openai'; \ No newline at end of file +export { OpenAIConversationsSession } from '@openai/agents-openai'; diff --git a/packages/agents-a365-tooling/docs/design.md b/packages/agents-a365-tooling/docs/design.md index 6068daab..860f8dea 100644 --- a/packages/agents-a365-tooling/docs/design.md +++ b/packages/agents-a365-tooling/docs/design.md @@ -12,6 +12,7 @@ The tooling package provides MCP (Model Context Protocol) tool server configurat ┌─────────────────────────────────────────────────────────────────┐ │ Public API │ │ McpToolServerConfigurationService | Utility | Contracts │ +│ ToolingConfiguration │ └─────────────────────────────────────────────────────────────────┘ │ ▼ @@ -60,17 +61,18 @@ const tools = await service.getMcpClientTools( #### Environment Detection -The service automatically selects the configuration source based on the `NODE_ENV` variable: +The service automatically selects the configuration source based on the `ToolingConfiguration.useToolingManifest` setting: -| Environment | Source | Description | -|-------------|--------|-------------| -| `Development` | `ToolingManifest.json` | Local file-based configuration | -| Other (default) | Tooling Gateway | HTTP endpoint discovery | +| Configuration | Source | Description | +|---------------|--------|-------------| +| `useToolingManifest: true` | `ToolingManifest.json` | Local file-based configuration | +| `useToolingManifest: false` (default) | Tooling Gateway | HTTP endpoint discovery | + +The `useToolingManifest` property checks `NODE_ENV === 'development'` by default, but can be overridden via configuration: ```typescript private isDevScenario(): boolean { - const environment = process.env.NODE_ENV || ''; - return environment.toLowerCase() === 'development'; + return defaultToolingConfigurationProvider.getConfiguration().useToolingManifest; } ``` @@ -109,7 +111,7 @@ User-Agent: Agent365SDK/x.x.x (...) ### Utility Class ([Utility.ts](../src/Utility.ts)) -Helper functions for URL construction, token validation, and header composition: +Helper functions for token validation and header composition: ```typescript import { Utility } from '@microsoft/agents-a365-tooling'; @@ -123,15 +125,18 @@ const headers = Utility.GetToolRequestHeaders( // Validate JWT token (throws if invalid or expired) Utility.ValidateAuthToken(authToken); +``` -// Build tooling gateway URL -const gatewayUrl = Utility.GetToolingGatewayForDigitalWorker(agenticAppId); -// => "https://agent365.svc.cloud.microsoft/agents/{agenticAppId}/mcpServers" +**Deprecated Methods:** -// Build MCP server URL -const serverUrl = Utility.BuildMcpServerUrl('MyServer'); -// => "https://agent365.svc.cloud.microsoft/agents/servers/MyServer" -``` +The following URL construction methods are deprecated and for internal use only. Use `McpToolServerConfigurationService` instead: + +| Method | Replacement | +|--------|-------------| +| `GetToolingGatewayForDigitalWorker()` | `McpToolServerConfigurationService.listToolServers()` | +| `GetMcpBaseUrl()` | Use `McpToolServerConfigurationService` | +| `BuildMcpServerUrl()` | Use `McpToolServerConfigurationService` | +| `GetChatHistoryEndpoint()` | `McpToolServerConfigurationService.sendChatHistory()` | **Header Constants:** @@ -216,6 +221,41 @@ async getMcpClientTools(serverName: string, config: MCPServerConfig): Promise 'https://custom.endpoint', + useToolingManifest: () => true, // Force manifest mode + mcpPlatformAuthenticationScope: () => 'custom-scope/.default' +}); +``` + +**Configuration Properties:** + +| Property | Env Variable | Default | Description | +|----------|--------------|---------|-------------| +| `mcpPlatformEndpoint` | `MCP_PLATFORM_ENDPOINT` | `https://agent365.svc.cloud.microsoft` | Base URL for MCP platform | +| `useToolingManifest` | `NODE_ENV` | `false` | Use local manifest (true if NODE_ENV='development') | +| `mcpPlatformAuthenticationScope` | `MCP_PLATFORM_AUTHENTICATION_SCOPE` | Production scope | OAuth scope for MCP platform auth | +| `clusterCategory` | `CLUSTER_CATEGORY` | `prod` | (Inherited) Environment cluster | +| `isDevelopmentEnvironment` | - | Derived | (Inherited) true if cluster is 'local' or 'dev' | +| `isNodeEnvDevelopment` | `NODE_ENV` | `false` | (Inherited) true if NODE_ENV='development' | + ## File Structure ``` @@ -223,15 +263,21 @@ src/ ├── index.ts # Public API exports ├── McpToolServerConfigurationService.ts # Main service ├── Utility.ts # Helper utilities -└── contracts.ts # Type definitions +├── contracts.ts # Type definitions +├── models.ts # Data models +└── configuration/ + ├── index.ts # Configuration exports + ├── ToolingConfigurationOptions.ts # Options type + └── ToolingConfiguration.ts # Configuration class ``` ## Environment Variables | Variable | Purpose | Default | |----------|---------|---------| -| `NODE_ENV` | Controls dev vs prod mode | Production | +| `NODE_ENV` | Controls useToolingManifest (dev mode) | Production | | `MCP_PLATFORM_ENDPOINT` | Base URL for MCP platform | `https://agent365.svc.cloud.microsoft` | +| `MCP_PLATFORM_AUTHENTICATION_SCOPE` | OAuth scope for MCP platform | Production scope | ## Error Handling diff --git a/packages/agents-a365-tooling/src/McpToolServerConfigurationService.ts b/packages/agents-a365-tooling/src/McpToolServerConfigurationService.ts index 093347c4..40578895 100644 --- a/packages/agents-a365-tooling/src/McpToolServerConfigurationService.ts +++ b/packages/agents-a365-tooling/src/McpToolServerConfigurationService.ts @@ -5,10 +5,11 @@ import fs from 'fs'; import path from 'path'; import axios from 'axios'; import { TurnContext, Authorization } from '@microsoft/agents-hosting'; -import { OperationResult, OperationError, AgenticAuthenticationService, Utility as RuntimeUtility } from '@microsoft/agents-a365-runtime'; +import { OperationResult, OperationError, IConfigurationProvider, AgenticAuthenticationService, Utility as RuntimeUtility } from '@microsoft/agents-a365-runtime'; import { MCPServerConfig, MCPServerManifestEntry, McpClientTool, ToolOptions } from './contracts'; import { ChatHistoryMessage, ChatMessageRequest } from './models/index'; import { Utility } from './Utility'; +import { ToolingConfiguration, defaultToolingConfigurationProvider } from './configuration'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -19,11 +20,14 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; */ export class McpToolServerConfigurationService { private readonly logger = console; + private readonly configProvider: IConfigurationProvider; /** * Construct a McpToolServerConfigurationService. + * @param configProvider Optional configuration provider. Defaults to defaultToolingConfigurationProvider if not specified. */ - constructor() { + constructor(configProvider?: IConfigurationProvider) { + this.configProvider = configProvider ?? defaultToolingConfigurationProvider; } /** @@ -101,7 +105,8 @@ export class McpToolServerConfigurationService { // Auto-generate token if not provided if (!authToken) { - authToken = await AgenticAuthenticationService.GetAgenticUserToken(authorization, authHandlerName, turnContext); + const scopes = [this.configProvider.getConfiguration().mcpPlatformAuthenticationScope]; + authToken = await AgenticAuthenticationService.GetAgenticUserToken(authorization, authHandlerName, turnContext, scopes); if (!authToken) { throw new Error('Failed to obtain authentication token from token exchange'); } @@ -209,7 +214,7 @@ export class McpToolServerConfigurationService { } // Get the endpoint URL - const endpoint = Utility.GetChatHistoryEndpoint(); + const endpoint = this.getChatHistoryEndpoint(); this.logger.info(`Sending chat history to endpoint: ${endpoint}`); @@ -269,7 +274,7 @@ export class McpToolServerConfigurationService { // Validate the authentication token Utility.ValidateAuthToken(authToken); - const configEndpoint = Utility.GetToolingGatewayForDigitalWorker(agenticAppId); + const configEndpoint = this.getToolingGatewayUrl(agenticAppId); try { const response = await axios.get( @@ -337,7 +342,7 @@ export class McpToolServerConfigurationService { } return { mcpServerName: serverName, - url: s.url || Utility.BuildMcpServerUrl(serverName), + url: s.url || this.buildMcpServerUrl(serverName), headers: s.headers }; }); @@ -349,12 +354,39 @@ export class McpToolServerConfigurationService { } /** - * Detect if the process is running in a development scenario based on environment variables. + * Detect if the process is running in a development scenario based on configuration. * - * @returns {boolean} True when running in a development environment. + * @returns {boolean} True when running in a development environment (NODE_ENV=Development). */ private isDevScenario(): boolean { - const environment = process.env.NODE_ENV || ''; - return environment.toLowerCase() === 'development'; + return this.configProvider.getConfiguration().useToolingManifest; + } + + /** + * Gets the base URL for MCP platform from configuration. + */ + private getMcpPlatformBaseUrl(): string { + return this.configProvider.getConfiguration().mcpPlatformEndpoint; + } + + /** + * Construct the tooling gateway URL for a given agent identity. + */ + private getToolingGatewayUrl(agenticAppId: string): string { + return `${this.getMcpPlatformBaseUrl()}/agents/${agenticAppId}/mcpServers`; + } + + /** + * Build the full URL for accessing a specific MCP server. + */ + private buildMcpServerUrl(serverName: string): string { + return `${this.getMcpPlatformBaseUrl()}/agents/servers/${serverName}/`; + } + + /** + * Constructs the endpoint URL for sending chat history. + */ + private getChatHistoryEndpoint(): string { + return `${this.getMcpPlatformBaseUrl()}/agents/real-time-threat-protection/chat-message`; } } diff --git a/packages/agents-a365-tooling/src/Utility.ts b/packages/agents-a365-tooling/src/Utility.ts index a7b944e7..32b0084b 100644 --- a/packages/agents-a365-tooling/src/Utility.ts +++ b/packages/agents-a365-tooling/src/Utility.ts @@ -3,12 +3,10 @@ import { TurnContext } from '@microsoft/agents-hosting'; import { ChannelAccount } from '@microsoft/agents-activity'; -import { Utility as RuntimeUtility } from '@microsoft/agents-a365-runtime'; +import { Utility as RuntimeUtility, IConfigurationProvider } from '@microsoft/agents-a365-runtime'; import { ToolOptions } from './contracts'; - -// Constant for MCP Platform base URL in production -const MCP_PLATFORM_PROD_BASE_URL = 'https://agent365.svc.cloud.microsoft'; +import { ToolingConfiguration, defaultToolingConfigurationProvider } from './configuration'; export class Utility { public static readonly HEADER_CHANNEL_ID = 'x-ms-channel-id'; @@ -158,20 +156,26 @@ export class Utility { * // => "https://agent365.svc.cloud.microsoft/agents/{agenticAppId}/mcpServers" * * @param agenticAppId - The unique identifier for the agent identity. + * @param configProvider - Optional configuration provider. Defaults to defaultToolingConfigurationProvider. * @returns A fully-qualified URL pointing at the tooling gateway for the agent. + * @deprecated This method is for internal use only. Use McpToolServerConfigurationService.listToolServers() instead. */ - public static GetToolingGatewayForDigitalWorker(agenticAppId: string): string { - // The endpoint needs to be updated based on the environment (prod, dev, etc.) - return `${this.getMcpPlatformBaseUrl()}/agents/${agenticAppId}/mcpServers`; + public static GetToolingGatewayForDigitalWorker( + agenticAppId: string, + configProvider?: IConfigurationProvider + ): string { + return `${this.getMcpPlatformBaseUrl(configProvider)}/agents/${agenticAppId}/mcpServers`; } /** * Get the base URL used to query MCP environments. * + * @param configProvider - Optional configuration provider. Defaults to defaultToolingConfigurationProvider. * @returns The base MCP environments URL. + * @deprecated This method is for internal use only. Use McpToolServerConfigurationService instead. */ - public static GetMcpBaseUrl(): string { - return `${this.getMcpPlatformBaseUrl()}/agents/servers`; + public static GetMcpBaseUrl(configProvider?: IConfigurationProvider): string { + return `${this.getMcpPlatformBaseUrl(configProvider)}/agents/servers`; } /** @@ -182,37 +186,43 @@ export class Utility { * // => "https://agent365.svc.cloud.microsoft/agents/servers/MyServer/" * * @param serverName - The MCP server resource name. + * @param configProvider - Optional configuration provider. Defaults to defaultToolingConfigurationProvider. * @returns The fully-qualified MCP server URL including trailing slash. + * @deprecated This method is for internal use only. Use McpToolServerConfigurationService instead. */ - public static BuildMcpServerUrl(serverName: string) : string { - const baseUrl = this.GetMcpBaseUrl(); - return `${baseUrl}/${serverName}`; + public static BuildMcpServerUrl( + serverName: string, + configProvider?: IConfigurationProvider + ): string { + const baseUrl = this.GetMcpBaseUrl(configProvider); + return `${baseUrl}/${serverName}/`; } /** - * Gets the base URL for MCP platform, defaults to production URL if not set. + * Gets the base URL for MCP platform from configuration. * + * @param configProvider - Optional configuration provider. Defaults to defaultToolingConfigurationProvider. * @returns The base URL for MCP platform. + * @deprecated This method is for internal use only. Use ToolingConfiguration.mcpPlatformEndpoint instead. */ - private static getMcpPlatformBaseUrl(): string { - if (process.env.MCP_PLATFORM_ENDPOINT) { - return process.env.MCP_PLATFORM_ENDPOINT; - } - - return MCP_PLATFORM_PROD_BASE_URL; + private static getMcpPlatformBaseUrl(configProvider?: IConfigurationProvider): string { + const provider = configProvider ?? defaultToolingConfigurationProvider; + return provider.getConfiguration().mcpPlatformEndpoint; } /** * Constructs the endpoint URL for sending chat history to the MCP platform for real-time threat protection. - * + * + * @param configProvider - Optional configuration provider. Defaults to defaultToolingConfigurationProvider. * @returns An absolute URL that tooling components can use to send or retrieve chat messages for * real-time threat protection scenarios. * @remarks * Call this method when constructing HTTP requests that need to access the chat-message history * for real-time threat protection. The returned URL already includes the MCP platform base address * and the fixed path segment `/agents/real-time-threat-protection/chat-message`. + * @deprecated This method is for internal use only. Use McpToolServerConfigurationService.sendChatHistory() instead. */ - public static GetChatHistoryEndpoint(): string { - return `${this.getMcpPlatformBaseUrl()}/agents/real-time-threat-protection/chat-message`; + public static GetChatHistoryEndpoint(configProvider?: IConfigurationProvider): string { + return `${this.getMcpPlatformBaseUrl(configProvider)}/agents/real-time-threat-protection/chat-message`; } } diff --git a/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts b/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts new file mode 100644 index 00000000..349791e0 --- /dev/null +++ b/packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RuntimeConfiguration } from '@microsoft/agents-a365-runtime'; +import { ToolingConfigurationOptions } from './ToolingConfigurationOptions'; + +// Constants for tooling-specific settings +const MCP_PLATFORM_PROD_BASE_URL = 'https://agent365.svc.cloud.microsoft'; +const PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE = 'ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default'; + +/** + * Normalize URL by trimming whitespace and removing trailing slashes. + * Prevents double-slash issues in URL construction (e.g., "https://example.com//api"). + */ +function normalizeUrl(url: string): string { + return url.trim().replace(/\/+$/, ''); +} + +/** + * Configuration for tooling package. + * Inherits runtime settings and adds tooling-specific settings. + */ +export class ToolingConfiguration extends RuntimeConfiguration { + // Type-safe access to tooling overrides + protected get toolingOverrides(): ToolingConfigurationOptions { + return this.overrides as ToolingConfigurationOptions; + } + + constructor(overrides?: ToolingConfigurationOptions) { + super(overrides); + } + + // Inherited: clusterCategory, isDevelopmentEnvironment, isNodeEnvDevelopment + + get mcpPlatformEndpoint(): string { + const override = this.toolingOverrides.mcpPlatformEndpoint?.(); + if (override) return normalizeUrl(override); + + const envValue = process.env.MCP_PLATFORM_ENDPOINT?.trim(); + if (envValue) return normalizeUrl(envValue); + + return MCP_PLATFORM_PROD_BASE_URL; + } + + /** + * Whether to use the ToolingManifest.json file instead of gateway discovery. + * Returns true when NODE_ENV is set to 'development' (case-insensitive), or + * when explicitly overridden via configuration. + */ + get useToolingManifest(): boolean { + const override = this.toolingOverrides.useToolingManifest?.(); + if (override !== undefined) return override; + + return this.isNodeEnvDevelopment; + } + + /** + * Gets the MCP platform authentication scope. + * Used by AgenticAuthenticationService for token exchange. + * Trims whitespace to prevent token exchange failures. + */ + get mcpPlatformAuthenticationScope(): string { + const override = this.toolingOverrides.mcpPlatformAuthenticationScope?.()?.trim(); + if (override) return override; + + const envValue = process.env.MCP_PLATFORM_AUTHENTICATION_SCOPE?.trim(); + if (envValue) return envValue; + + return PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE; + } +} diff --git a/packages/agents-a365-tooling/src/configuration/ToolingConfigurationOptions.ts b/packages/agents-a365-tooling/src/configuration/ToolingConfigurationOptions.ts new file mode 100644 index 00000000..be68c34c --- /dev/null +++ b/packages/agents-a365-tooling/src/configuration/ToolingConfigurationOptions.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RuntimeConfigurationOptions } from '@microsoft/agents-a365-runtime'; + +/** + * Tooling configuration options - extends runtime options. + * All overrides are functions called on each property access. + * + * Inherited from RuntimeConfigurationOptions: + * - clusterCategory + * - isNodeEnvDevelopment + */ +export type ToolingConfigurationOptions = RuntimeConfigurationOptions & { + mcpPlatformEndpoint?: () => string; + /** + * Override for using ToolingManifest.json vs gateway discovery. + * Falls back to inherited isNodeEnvDevelopment. + */ + useToolingManifest?: () => boolean; + /** + * Override for MCP platform authentication scope. + * Falls back to MCP_PLATFORM_AUTHENTICATION_SCOPE env var, then production default. + */ + mcpPlatformAuthenticationScope?: () => string; +}; diff --git a/packages/agents-a365-tooling/src/configuration/index.ts b/packages/agents-a365-tooling/src/configuration/index.ts new file mode 100644 index 00000000..0564cf20 --- /dev/null +++ b/packages/agents-a365-tooling/src/configuration/index.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { ToolingConfiguration } from './ToolingConfiguration'; + +export * from './ToolingConfigurationOptions'; +export * from './ToolingConfiguration'; + +/** + * Shared default provider for ToolingConfiguration. + */ +export const defaultToolingConfigurationProvider = + new DefaultConfigurationProvider(() => new ToolingConfiguration()); diff --git a/packages/agents-a365-tooling/src/index.ts b/packages/agents-a365-tooling/src/index.ts index 238650c6..47b6bc78 100644 --- a/packages/agents-a365-tooling/src/index.ts +++ b/packages/agents-a365-tooling/src/index.ts @@ -1,4 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + export * from './Utility'; export * from './McpToolServerConfigurationService'; export * from './contracts'; -export * from './models'; \ No newline at end of file +export * from './models'; +export * from './configuration'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1eb41fe..a9ecfba1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ catalogs: '@langchain/core': specifier: ^1.1.8 version: 1.1.8 + '@langchain/langgraph': + specifier: ^1.0.0 + version: 1.1.2 '@langchain/mcp-adapters': specifier: ^1.1.1 version: 1.1.1 @@ -330,6 +333,9 @@ importers: '@microsoft/agents-a365-observability': specifier: workspace:* version: link:../agents-a365-observability + '@microsoft/agents-a365-runtime': + specifier: workspace:* + version: link:../agents-a365-runtime '@openai/agents': specifier: 'catalog:' version: 0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) diff --git a/tests/jest.config.cjs b/tests/jest.config.cjs index 7478df80..3627b5ff 100644 --- a/tests/jest.config.cjs +++ b/tests/jest.config.cjs @@ -19,6 +19,12 @@ module.exports = { '**/?(*.)+(spec|test).js' ], + // Exclude integration tests (they require Azure OpenAI credentials) + testPathIgnorePatterns: [ + '/node_modules/', + '/integration/' + ], + // Transform TypeScript files transform: { '^.+\\.ts$': ['ts-jest', { diff --git a/tests/observability-extensions-openai/configuration/OpenAIObservabilityConfiguration.test.ts b/tests/observability-extensions-openai/configuration/OpenAIObservabilityConfiguration.test.ts new file mode 100644 index 00000000..0ed960f4 --- /dev/null +++ b/tests/observability-extensions-openai/configuration/OpenAIObservabilityConfiguration.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + OpenAIObservabilityConfiguration, + defaultOpenAIObservabilityConfigurationProvider +} from '../../../packages/agents-a365-observability-extensions-openai/src'; +import { ObservabilityConfiguration } from '../../../packages/agents-a365-observability/src'; +import { RuntimeConfiguration, DefaultConfigurationProvider, ClusterCategory } from '../../../packages/agents-a365-runtime/src'; + +describe('OpenAIObservabilityConfiguration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('inheritance from ObservabilityConfiguration', () => { + it('should inherit observability settings', () => { + const config = new OpenAIObservabilityConfiguration({ + isObservabilityExporterEnabled: () => true + }); + expect(config.isObservabilityExporterEnabled).toBe(true); + }); + + it('should be instanceof ObservabilityConfiguration', () => { + const config = new OpenAIObservabilityConfiguration(); + expect(config).toBeInstanceOf(ObservabilityConfiguration); + }); + + it('should be instanceof RuntimeConfiguration', () => { + const config = new OpenAIObservabilityConfiguration(); + expect(config).toBeInstanceOf(RuntimeConfiguration); + }); + }); + + describe('inherited runtime settings', () => { + it('should inherit clusterCategory from override', () => { + const config = new OpenAIObservabilityConfiguration({ clusterCategory: () => ClusterCategory.gov }); + expect(config.clusterCategory).toBe(ClusterCategory.gov); + }); + + it('should inherit clusterCategory from env var', () => { + process.env.CLUSTER_CATEGORY = 'dev'; + const config = new OpenAIObservabilityConfiguration({}); + expect(config.clusterCategory).toBe('dev'); + }); + + it('should inherit isDevelopmentEnvironment', () => { + const config = new OpenAIObservabilityConfiguration({ clusterCategory: () => ClusterCategory.local }); + expect(config.isDevelopmentEnvironment).toBe(true); + }); + }); + + describe('inherited observability settings', () => { + it('should inherit isObservabilityExporterEnabled from env var', () => { + process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; + const config = new OpenAIObservabilityConfiguration({}); + expect(config.isObservabilityExporterEnabled).toBe(true); + }); + + it('should inherit observabilityAuthenticationScopes from override', () => { + const config = new OpenAIObservabilityConfiguration({ + observabilityAuthenticationScopes: () => ['custom-scope/.default'] + }); + expect(config.observabilityAuthenticationScopes).toEqual(['custom-scope/.default']); + }); + + it('should inherit useCustomDomainForObservability from override', () => { + const config = new OpenAIObservabilityConfiguration({ + useCustomDomainForObservability: () => true + }); + expect(config.useCustomDomainForObservability).toBe(true); + }); + + it('should inherit observabilityDomainOverride from override', () => { + const config = new OpenAIObservabilityConfiguration({ + observabilityDomainOverride: () => 'https://custom.domain' + }); + expect(config.observabilityDomainOverride).toBe('https://custom.domain'); + }); + + it('should inherit observabilityLogLevel from override', () => { + const config = new OpenAIObservabilityConfiguration({ + observabilityLogLevel: () => 'debug' + }); + expect(config.observabilityLogLevel).toBe('debug'); + }); + + it('should use default isObservabilityExporterEnabled when not overridden', () => { + delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; + const config = new OpenAIObservabilityConfiguration({}); + expect(config.isObservabilityExporterEnabled).toBe(false); + }); + }); + + describe('combined overrides', () => { + it('should allow overriding runtime and observability settings together', () => { + const config = new OpenAIObservabilityConfiguration({ + clusterCategory: () => ClusterCategory.dev, + isObservabilityExporterEnabled: () => true, + observabilityLogLevel: () => 'info' + }); + expect(config.clusterCategory).toBe(ClusterCategory.dev); + expect(config.isDevelopmentEnvironment).toBe(true); + expect(config.isObservabilityExporterEnabled).toBe(true); + expect(config.observabilityLogLevel).toBe('info'); + }); + + it('should support dynamic per-tenant configuration', () => { + let currentTenant = 'tenant-a'; + const tenantSettings: Record = { + 'tenant-a': true, + 'tenant-b': false + }; + + const config = new OpenAIObservabilityConfiguration({ + isObservabilityExporterEnabled: () => tenantSettings[currentTenant] + }); + + expect(config.isObservabilityExporterEnabled).toBe(true); + currentTenant = 'tenant-b'; + expect(config.isObservabilityExporterEnabled).toBe(false); + }); + }); + + describe('constructor', () => { + it('should accept no overrides', () => { + const config = new OpenAIObservabilityConfiguration(); + expect(config).toBeInstanceOf(OpenAIObservabilityConfiguration); + }); + + it('should accept empty overrides object', () => { + const config = new OpenAIObservabilityConfiguration({}); + expect(config).toBeInstanceOf(OpenAIObservabilityConfiguration); + }); + + it('should accept undefined overrides', () => { + const config = new OpenAIObservabilityConfiguration(undefined); + expect(config).toBeInstanceOf(OpenAIObservabilityConfiguration); + }); + }); +}); + +describe('defaultOpenAIObservabilityConfigurationProvider', () => { + it('should be an instance of DefaultConfigurationProvider', () => { + expect(defaultOpenAIObservabilityConfigurationProvider).toBeInstanceOf(DefaultConfigurationProvider); + }); + + it('should return OpenAIObservabilityConfiguration from getConfiguration', () => { + const config = defaultOpenAIObservabilityConfigurationProvider.getConfiguration(); + expect(config).toBeInstanceOf(OpenAIObservabilityConfiguration); + }); + + it('should return the same configuration instance on multiple calls', () => { + const config1 = defaultOpenAIObservabilityConfigurationProvider.getConfiguration(); + const config2 = defaultOpenAIObservabilityConfigurationProvider.getConfiguration(); + expect(config1).toBe(config2); + }); +}); diff --git a/tests/observability/configuration/ObservabilityConfiguration.test.ts b/tests/observability/configuration/ObservabilityConfiguration.test.ts new file mode 100644 index 00000000..fc83817b --- /dev/null +++ b/tests/observability/configuration/ObservabilityConfiguration.test.ts @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + ObservabilityConfiguration, + defaultObservabilityConfigurationProvider +} from '../../../packages/agents-a365-observability/src'; +import { RuntimeConfiguration, DefaultConfigurationProvider, ClusterCategory } from '../../../packages/agents-a365-runtime/src'; + +describe('ObservabilityConfiguration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('inheritance from RuntimeConfiguration', () => { + it('should inherit runtime settings', () => { + const config = new ObservabilityConfiguration({ clusterCategory: () => ClusterCategory.gov }); + expect(config.clusterCategory).toBe(ClusterCategory.gov); + expect(config.isDevelopmentEnvironment).toBe(false); + }); + + it('should be instanceof RuntimeConfiguration', () => { + const config = new ObservabilityConfiguration(); + expect(config).toBeInstanceOf(RuntimeConfiguration); + }); + }); + + describe('observabilityAuthenticationScopes', () => { + it('should use override function when provided', () => { + const config = new ObservabilityConfiguration({ + observabilityAuthenticationScopes: () => ['scope1/.default', 'scope2/.default'] + }); + expect(config.observabilityAuthenticationScopes).toEqual(['scope1/.default', 'scope2/.default']); + }); + + it('should fall back to env var when override returns undefined', () => { + process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE = 'custom-scope/.default'; + const config = new ObservabilityConfiguration({ + observabilityAuthenticationScopes: () => undefined as unknown as string[] + }); + expect(config.observabilityAuthenticationScopes).toEqual(['custom-scope/.default']); + }); + + it('should fall back to default when override returns undefined and no env var', () => { + delete process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE; + const config = new ObservabilityConfiguration({ + observabilityAuthenticationScopes: () => undefined as unknown as string[] + }); + expect(config.observabilityAuthenticationScopes).toEqual(['https://api.powerplatform.com/.default']); + }); + + it('should fall back to env var when override not provided', () => { + process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE = 'scope-a/.default scope-b/.default'; + const config = new ObservabilityConfiguration({}); + expect(config.observabilityAuthenticationScopes).toEqual(['scope-a/.default', 'scope-b/.default']); + }); + + it('should fall back to default when neither override nor env var', () => { + delete process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE; + const config = new ObservabilityConfiguration({}); + expect(config.observabilityAuthenticationScopes).toEqual(['https://api.powerplatform.com/.default']); + }); + + it('should fall back to default when env var is empty string', () => { + process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE = ''; + const config = new ObservabilityConfiguration(); + expect(config.observabilityAuthenticationScopes).toEqual(['https://api.powerplatform.com/.default']); + }); + + it('should fall back to default when env var is whitespace only', () => { + process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE = ' '; + const config = new ObservabilityConfiguration(); + expect(config.observabilityAuthenticationScopes).toEqual(['https://api.powerplatform.com/.default']); + }); + + it('should return readonly array', () => { + const config = new ObservabilityConfiguration({}); + const scopes = config.observabilityAuthenticationScopes; + expect(Array.isArray(scopes)).toBe(true); + }); + }); + + describe('isObservabilityExporterEnabled', () => { + it('should use override function when provided', () => { + const config = new ObservabilityConfiguration({ + isObservabilityExporterEnabled: () => true + }); + expect(config.isObservabilityExporterEnabled).toBe(true); + }); + + it.each([ + ['true', true], + ['TRUE', true], + ['1', true], + ['yes', true], + ['on', true], + ['false', false], + ['0', false], + ['no', false], + ['', false] + ])('should return %s when env var is "%s"', (envValue, expected) => { + process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = envValue; + const config = new ObservabilityConfiguration({}); + expect(config.isObservabilityExporterEnabled).toBe(expected); + }); + + it('should return false when env var is not set', () => { + delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; + const config = new ObservabilityConfiguration({}); + expect(config.isObservabilityExporterEnabled).toBe(false); + }); + + it('should call override function on each access (dynamic resolution)', () => { + let callCount = 0; + const config = new ObservabilityConfiguration({ + isObservabilityExporterEnabled: () => { + callCount++; + return true; + } + }); + void config.isObservabilityExporterEnabled; + void config.isObservabilityExporterEnabled; + expect(callCount).toBe(2); + }); + }); + + describe('useCustomDomainForObservability', () => { + it('should use override function when provided', () => { + const config = new ObservabilityConfiguration({ + useCustomDomainForObservability: () => true + }); + expect(config.useCustomDomainForObservability).toBe(true); + }); + + it.each([ + ['true', true], + ['1', true], + ['yes', true], + ['on', true], + ['false', false], + ['0', false], + ['', false] + ])('should return %s when env var is "%s"', (envValue, expected) => { + process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN = envValue; + const config = new ObservabilityConfiguration({}); + expect(config.useCustomDomainForObservability).toBe(expected); + }); + + it('should return false when env var is not set', () => { + delete process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN; + const config = new ObservabilityConfiguration({}); + expect(config.useCustomDomainForObservability).toBe(false); + }); + + it('should call override function on each access (dynamic resolution)', () => { + let callCount = 0; + const config = new ObservabilityConfiguration({ + useCustomDomainForObservability: () => { + callCount++; + return true; + } + }); + void config.useCustomDomainForObservability; + void config.useCustomDomainForObservability; + expect(callCount).toBe(2); + }); + }); + + describe('observabilityDomainOverride', () => { + it('should use override function when provided', () => { + const config = new ObservabilityConfiguration({ + observabilityDomainOverride: () => 'https://custom.domain' + }); + expect(config.observabilityDomainOverride).toBe('https://custom.domain'); + }); + + it('should return trimmed value from env var', () => { + process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = ' https://env.domain '; + const config = new ObservabilityConfiguration({}); + expect(config.observabilityDomainOverride).toBe('https://env.domain'); + }); + + it('should remove trailing slashes', () => { + process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://env.domain///'; + const config = new ObservabilityConfiguration({}); + expect(config.observabilityDomainOverride).toBe('https://env.domain'); + }); + + it('should return null when env var is not set', () => { + delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; + const config = new ObservabilityConfiguration({}); + expect(config.observabilityDomainOverride).toBeNull(); + }); + + it('should return null when env var is whitespace', () => { + process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = ' '; + const config = new ObservabilityConfiguration({}); + expect(config.observabilityDomainOverride).toBeNull(); + }); + }); + + describe('observabilityLogLevel', () => { + it('should use override function when provided', () => { + const config = new ObservabilityConfiguration({ + observabilityLogLevel: () => 'info|warn' + }); + expect(config.observabilityLogLevel).toBe('info|warn'); + }); + + it('should fall back to env var when override not provided', () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; + const config = new ObservabilityConfiguration({}); + expect(config.observabilityLogLevel).toBe('error'); + }); + + it('should fall back to none when neither override nor env var', () => { + delete process.env.A365_OBSERVABILITY_LOG_LEVEL; + const config = new ObservabilityConfiguration({}); + expect(config.observabilityLogLevel).toBe('none'); + }); + }); + +}); + +describe('defaultObservabilityConfigurationProvider', () => { + it('should be an instance of DefaultConfigurationProvider', () => { + expect(defaultObservabilityConfigurationProvider).toBeInstanceOf(DefaultConfigurationProvider); + }); + + it('should return ObservabilityConfiguration from getConfiguration', () => { + const config = defaultObservabilityConfigurationProvider.getConfiguration(); + expect(config).toBeInstanceOf(ObservabilityConfiguration); + }); + + it('should return the same configuration instance on multiple calls', () => { + const config1 = defaultObservabilityConfigurationProvider.getConfiguration(); + const config2 = defaultObservabilityConfigurationProvider.getConfiguration(); + expect(config1).toBe(config2); + }); +}); diff --git a/tests/observability/configuration/PerRequestSpanProcessorConfiguration.test.ts b/tests/observability/configuration/PerRequestSpanProcessorConfiguration.test.ts new file mode 100644 index 00000000..7abdf668 --- /dev/null +++ b/tests/observability/configuration/PerRequestSpanProcessorConfiguration.test.ts @@ -0,0 +1,263 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + PerRequestSpanProcessorConfiguration, + defaultPerRequestSpanProcessorConfigurationProvider, + ObservabilityConfiguration +} from '../../../packages/agents-a365-observability/src'; +import { RuntimeConfiguration, DefaultConfigurationProvider, ClusterCategory } from '../../../packages/agents-a365-runtime/src'; + +describe('PerRequestSpanProcessorConfiguration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('inheritance', () => { + it('should be instanceof ObservabilityConfiguration', () => { + const config = new PerRequestSpanProcessorConfiguration(); + expect(config).toBeInstanceOf(ObservabilityConfiguration); + }); + + it('should be instanceof RuntimeConfiguration', () => { + const config = new PerRequestSpanProcessorConfiguration(); + expect(config).toBeInstanceOf(RuntimeConfiguration); + }); + + it('should inherit runtime settings', () => { + const config = new PerRequestSpanProcessorConfiguration({ clusterCategory: () => ClusterCategory.gov }); + expect(config.clusterCategory).toBe(ClusterCategory.gov); + expect(config.isDevelopmentEnvironment).toBe(false); + }); + + it('should inherit observability settings', () => { + const config = new PerRequestSpanProcessorConfiguration({ + isObservabilityExporterEnabled: () => true, + observabilityLogLevel: () => 'debug' + }); + expect(config.isObservabilityExporterEnabled).toBe(true); + expect(config.observabilityLogLevel).toBe('debug'); + }); + }); + + describe('isPerRequestExportEnabled', () => { + it('should use override function when provided', () => { + const config = new PerRequestSpanProcessorConfiguration({ + isPerRequestExportEnabled: () => true + }); + expect(config.isPerRequestExportEnabled).toBe(true); + }); + + it.each([ + ['true', true], + ['1', true], + ['yes', true], + ['on', true], + ['false', false], + ['0', false], + ['', false] + ])('should return %s when env var is "%s"', (envValue, expected) => { + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = envValue; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.isPerRequestExportEnabled).toBe(expected); + }); + + it('should return false when env var is not set', () => { + delete process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.isPerRequestExportEnabled).toBe(false); + }); + + it('should call override function on each access (dynamic resolution)', () => { + let callCount = 0; + const config = new PerRequestSpanProcessorConfiguration({ + isPerRequestExportEnabled: () => { + callCount++; + return true; + } + }); + void config.isPerRequestExportEnabled; + void config.isPerRequestExportEnabled; + expect(callCount).toBe(2); + }); + }); + + describe('perRequestMaxTraces', () => { + it('should use override function when provided', () => { + const config = new PerRequestSpanProcessorConfiguration({ + perRequestMaxTraces: () => 500 + }); + expect(config.perRequestMaxTraces).toBe(500); + }); + + it('should fall back to env var when override not provided', () => { + process.env.A365_PER_REQUEST_MAX_TRACES = '2000'; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxTraces).toBe(2000); + }); + + it('should fall back to default 1000 when neither override nor env var', () => { + delete process.env.A365_PER_REQUEST_MAX_TRACES; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxTraces).toBe(1000); + }); + + it('should fall back to default for invalid env var', () => { + process.env.A365_PER_REQUEST_MAX_TRACES = 'invalid'; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxTraces).toBe(1000); + }); + + it('should fall back to default for negative values from env var', () => { + process.env.A365_PER_REQUEST_MAX_TRACES = '-100'; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxTraces).toBe(1000); + }); + + it('should fall back to default for zero from env var', () => { + process.env.A365_PER_REQUEST_MAX_TRACES = '0'; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxTraces).toBe(1000); + }); + + it('should fall back to default for negative override', () => { + const config = new PerRequestSpanProcessorConfiguration({ + perRequestMaxTraces: () => -50 + }); + expect(config.perRequestMaxTraces).toBe(1000); + }); + }); + + describe('perRequestMaxSpansPerTrace', () => { + it('should use override function when provided', () => { + const config = new PerRequestSpanProcessorConfiguration({ + perRequestMaxSpansPerTrace: () => 10000 + }); + expect(config.perRequestMaxSpansPerTrace).toBe(10000); + }); + + it('should fall back to env var when override not provided', () => { + process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = '8000'; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxSpansPerTrace).toBe(8000); + }); + + it('should fall back to default 5000 when neither override nor env var', () => { + delete process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxSpansPerTrace).toBe(5000); + }); + + it('should fall back to default for invalid env var', () => { + process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = 'not-a-number'; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxSpansPerTrace).toBe(5000); + }); + + it('should fall back to default for negative values from env var', () => { + process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = '-500'; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxSpansPerTrace).toBe(5000); + }); + + it('should fall back to default for zero from env var', () => { + process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = '0'; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxSpansPerTrace).toBe(5000); + }); + + it('should fall back to default for negative override', () => { + const config = new PerRequestSpanProcessorConfiguration({ + perRequestMaxSpansPerTrace: () => -100 + }); + expect(config.perRequestMaxSpansPerTrace).toBe(5000); + }); + }); + + describe('perRequestMaxConcurrentExports', () => { + it('should use override function when provided', () => { + const config = new PerRequestSpanProcessorConfiguration({ + perRequestMaxConcurrentExports: () => 50 + }); + expect(config.perRequestMaxConcurrentExports).toBe(50); + }); + + it('should fall back to env var when override not provided', () => { + process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = '30'; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxConcurrentExports).toBe(30); + }); + + it('should fall back to default 20 when neither override nor env var', () => { + delete process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxConcurrentExports).toBe(20); + }); + + it('should fall back to default for invalid env var', () => { + process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = ''; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxConcurrentExports).toBe(20); + }); + + it('should fall back to default for negative values from env var', () => { + process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = '-10'; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxConcurrentExports).toBe(20); + }); + + it('should fall back to default for zero from env var', () => { + process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = '0'; + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config.perRequestMaxConcurrentExports).toBe(20); + }); + + it('should fall back to default for negative override', () => { + const config = new PerRequestSpanProcessorConfiguration({ + perRequestMaxConcurrentExports: () => -5 + }); + expect(config.perRequestMaxConcurrentExports).toBe(20); + }); + }); + + describe('constructor', () => { + it('should accept no overrides', () => { + const config = new PerRequestSpanProcessorConfiguration(); + expect(config).toBeInstanceOf(PerRequestSpanProcessorConfiguration); + }); + + it('should accept empty overrides object', () => { + const config = new PerRequestSpanProcessorConfiguration({}); + expect(config).toBeInstanceOf(PerRequestSpanProcessorConfiguration); + }); + + it('should accept undefined overrides', () => { + const config = new PerRequestSpanProcessorConfiguration(undefined); + expect(config).toBeInstanceOf(PerRequestSpanProcessorConfiguration); + }); + }); +}); + +describe('defaultPerRequestSpanProcessorConfigurationProvider', () => { + it('should be an instance of DefaultConfigurationProvider', () => { + expect(defaultPerRequestSpanProcessorConfigurationProvider).toBeInstanceOf(DefaultConfigurationProvider); + }); + + it('should return PerRequestSpanProcessorConfiguration from getConfiguration', () => { + const config = defaultPerRequestSpanProcessorConfigurationProvider.getConfiguration(); + expect(config).toBeInstanceOf(PerRequestSpanProcessorConfiguration); + }); + + it('should return the same configuration instance on multiple calls', () => { + const config1 = defaultPerRequestSpanProcessorConfigurationProvider.getConfiguration(); + const config2 = defaultPerRequestSpanProcessorConfigurationProvider.getConfiguration(); + expect(config1).toBe(config2); + }); +}); diff --git a/tests/observability/core/PerRequestSpanProcessor.test.ts b/tests/observability/core/PerRequestSpanProcessor.test.ts index 5048fd3c..f0c593c3 100644 --- a/tests/observability/core/PerRequestSpanProcessor.test.ts +++ b/tests/observability/core/PerRequestSpanProcessor.test.ts @@ -485,5 +485,81 @@ describe('PerRequestSpanProcessor', () => { nowSpy.mockRestore(); } }); + + it('should use default values when env vars are not set', async () => { + delete process.env.A365_PER_REQUEST_MAX_TRACES; + delete process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE; + delete process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS; + + await recreateProvider(new PerRequestSpanProcessor(mockExporter)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = processor as any; + expect(proc.maxBufferedTraces).toBe(1000); + expect(proc.maxSpansPerTrace).toBe(5000); + expect(proc.maxConcurrentExports).toBe(20); + }); + + it('should fallback to defaults for invalid env var values', async () => { + process.env.A365_PER_REQUEST_MAX_TRACES = 'not-a-number'; + process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = ''; + process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = 'NaN'; + + await recreateProvider(new PerRequestSpanProcessor(mockExporter)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = processor as any; + expect(proc.maxBufferedTraces).toBe(1000); + expect(proc.maxSpansPerTrace).toBe(5000); + expect(proc.maxConcurrentExports).toBe(20); + }); + + it('should parse valid numeric string env vars correctly', async () => { + process.env.A365_PER_REQUEST_MAX_TRACES = '50'; + process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = '100'; + process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = '5'; + + await recreateProvider(new PerRequestSpanProcessor(mockExporter)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = processor as any; + expect(proc.maxBufferedTraces).toBe(50); + expect(proc.maxSpansPerTrace).toBe(100); + expect(proc.maxConcurrentExports).toBe(5); + }); + + it('should handle shutdown gracefully', async () => { + const tracer = provider.getTracer('test'); + + runWithExportToken('test-token', () => { + const span = tracer.startSpan('root'); + span.end(); + }); + + // Shutdown should complete without throwing + await expect(processor.shutdown()).resolves.not.toThrow(); + }); + + it('should handle onStart with null parentSpanContext as root span', async () => { + const tracer = provider.getTracer('test'); + + await new Promise((resolve) => { + runWithExportToken('test-token', () => { + // Root span has no parent + const rootSpan = tracer.startSpan('root', { root: true }); + rootSpan.end(); + + setTimeout(() => resolve(), 50); + }); + }); + + expect(exportedSpans.length).toBe(1); + expect(exportedSpans[0][0].name).toBe('root'); + }); + + it('should handle empty traces array in forceFlush', async () => { + // No spans created, forceFlush should still complete + await expect(processor.forceFlush()).resolves.not.toThrow(); + }); }); }); diff --git a/tests/observability/core/observabilityBuilder-options.test.ts b/tests/observability/core/observabilityBuilder-options.test.ts index 1da05640..3388a9e3 100644 --- a/tests/observability/core/observabilityBuilder-options.test.ts +++ b/tests/observability/core/observabilityBuilder-options.test.ts @@ -3,6 +3,7 @@ // ------------------------------------------------------------------------------ import { ObservabilityBuilder } from '@microsoft/agents-a365-observability/src/ObservabilityBuilder'; +import { ClusterCategory } from '@microsoft/agents-a365-runtime'; // Mock the Agent365Exporter so we can capture the constructed options without performing network calls. jest.mock('@microsoft/agents-a365-observability/src/tracing/exporter/Agent365Exporter', () => { @@ -42,10 +43,10 @@ describe('ObservabilityBuilder exporterOptions merging', () => { exporterTimeoutMilliseconds: 2222, maxExportBatchSize: 33, // These should be overridden by explicit builder methods below - clusterCategory: 'dev' as any, + clusterCategory: ClusterCategory.dev, tokenResolver: () => 'token-from-exporterOptions' }) - .withClusterCategory('test') + .withClusterCategory(ClusterCategory.test) .withTokenResolver(() => 'token-from-builder'); const built = builder.build(); diff --git a/tests/observability/tracing/exporter-utils.test.ts b/tests/observability/tracing/exporter-utils.test.ts new file mode 100644 index 00000000..a8618649 --- /dev/null +++ b/tests/observability/tracing/exporter-utils.test.ts @@ -0,0 +1,460 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { SpanKind, SpanStatusCode } from '@opentelemetry/api'; +import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { ClusterCategory } from '@microsoft/agents-a365-runtime'; + +describe('exporter/utils', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + // Suppress logger output during tests + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + describe('hexTraceId', () => { + it('should convert number to 32-character hex string', async () => { + const { hexTraceId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + expect(hexTraceId(255)).toBe('000000000000000000000000000000ff'); + expect(hexTraceId(0)).toBe('00000000000000000000000000000000'); + expect(hexTraceId(4096)).toBe('00000000000000000000000000001000'); + }); + + it('should handle hex string input with 0x prefix', async () => { + const { hexTraceId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + expect(hexTraceId('0xabc')).toBe('00000000000000000000000000000abc'); + expect(hexTraceId('0x1234567890abcdef')).toBe('00000000000000001234567890abcdef'); + }); + + it('should handle hex string input without prefix', async () => { + const { hexTraceId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + expect(hexTraceId('abc')).toBe('00000000000000000000000000000abc'); + expect(hexTraceId('1234567890abcdef1234567890abcdef')).toBe('1234567890abcdef1234567890abcdef'); + }); + + it('should pad short strings to 32 characters', async () => { + const { hexTraceId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + const result = hexTraceId('1'); + expect(result.length).toBe(32); + expect(result).toBe('00000000000000000000000000000001'); + }); + }); + + describe('hexSpanId', () => { + it('should convert number to 16-character hex string', async () => { + const { hexSpanId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + expect(hexSpanId(255)).toBe('00000000000000ff'); + expect(hexSpanId(0)).toBe('0000000000000000'); + expect(hexSpanId(4096)).toBe('0000000000001000'); + }); + + it('should handle hex string input with 0x prefix', async () => { + const { hexSpanId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + expect(hexSpanId('0xabc')).toBe('0000000000000abc'); + }); + + it('should handle hex string input without prefix', async () => { + const { hexSpanId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + expect(hexSpanId('1234567890abcdef')).toBe('1234567890abcdef'); + }); + + it('should pad short strings to 16 characters', async () => { + const { hexSpanId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + const result = hexSpanId('1'); + expect(result.length).toBe(16); + expect(result).toBe('0000000000000001'); + }); + }); + + describe('asStr', () => { + it('should return undefined for null', async () => { + const { asStr } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(asStr(null)).toBeUndefined(); + }); + + it('should return undefined for undefined', async () => { + const { asStr } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(asStr(undefined)).toBeUndefined(); + }); + + it('should convert values to string', async () => { + const { asStr } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + expect(asStr('hello')).toBe('hello'); + expect(asStr(123)).toBe('123'); + expect(asStr(true)).toBe('true'); + expect(asStr(false)).toBe('false'); + }); + + it('should return undefined for empty or whitespace-only strings', async () => { + const { asStr } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + expect(asStr('')).toBeUndefined(); + expect(asStr(' ')).toBeUndefined(); + expect(asStr('\t\n')).toBeUndefined(); + }); + + it('should preserve non-empty strings with whitespace', async () => { + const { asStr } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + expect(asStr(' hello ')).toBe(' hello '); + expect(asStr('hello world')).toBe('hello world'); + }); + }); + + describe('kindName', () => { + it('should return INTERNAL for SpanKind.INTERNAL', async () => { + const { kindName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(kindName(SpanKind.INTERNAL)).toBe('INTERNAL'); + }); + + it('should return SERVER for SpanKind.SERVER', async () => { + const { kindName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(kindName(SpanKind.SERVER)).toBe('SERVER'); + }); + + it('should return CLIENT for SpanKind.CLIENT', async () => { + const { kindName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(kindName(SpanKind.CLIENT)).toBe('CLIENT'); + }); + + it('should return PRODUCER for SpanKind.PRODUCER', async () => { + const { kindName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(kindName(SpanKind.PRODUCER)).toBe('PRODUCER'); + }); + + it('should return CONSUMER for SpanKind.CONSUMER', async () => { + const { kindName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(kindName(SpanKind.CONSUMER)).toBe('CONSUMER'); + }); + + it('should return UNSPECIFIED for unknown kind', async () => { + const { kindName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(kindName(999 as SpanKind)).toBe('UNSPECIFIED'); + }); + }); + + describe('statusName', () => { + it('should return UNSET for SpanStatusCode.UNSET', async () => { + const { statusName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(statusName(SpanStatusCode.UNSET)).toBe('UNSET'); + }); + + it('should return OK for SpanStatusCode.OK', async () => { + const { statusName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(statusName(SpanStatusCode.OK)).toBe('OK'); + }); + + it('should return ERROR for SpanStatusCode.ERROR', async () => { + const { statusName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(statusName(SpanStatusCode.ERROR)).toBe('ERROR'); + }); + + it('should return UNSET for unknown status code', async () => { + const { statusName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(statusName(999 as SpanStatusCode)).toBe('UNSET'); + }); + }); + + describe('isAgent365ExporterEnabled', () => { + it.each([ + { value: 'true', expected: true }, + { value: 'TRUE', expected: true }, + { value: '1', expected: true }, + { value: 'yes', expected: true }, + { value: 'YES', expected: true }, + { value: 'on', expected: true }, + { value: 'ON', expected: true }, + ])('should return $expected when ENABLE_A365_OBSERVABILITY_EXPORTER is "$value"', async ({ value, expected }) => { + process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = value; + const { isAgent365ExporterEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(isAgent365ExporterEnabled()).toBe(expected); + }); + + it.each([ + { value: 'false', expected: false }, + { value: 'FALSE', expected: false }, + { value: '0', expected: false }, + { value: 'no', expected: false }, + { value: 'off', expected: false }, + { value: '', expected: false }, + { value: 'invalid', expected: false }, + ])('should return $expected when ENABLE_A365_OBSERVABILITY_EXPORTER is "$value"', async ({ value, expected }) => { + process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = value; + const { isAgent365ExporterEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(isAgent365ExporterEnabled()).toBe(expected); + }); + + it('should return false when env var is not set', async () => { + delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; + const { isAgent365ExporterEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(isAgent365ExporterEnabled()).toBe(false); + }); + }); + + describe('isPerRequestExportEnabled', () => { + it.each([ + { value: 'true', expected: true }, + { value: 'TRUE', expected: true }, + { value: '1', expected: true }, + { value: 'yes', expected: true }, + { value: 'on', expected: true }, + ])('should return $expected when ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT is "$value"', async ({ value, expected }) => { + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = value; + const { isPerRequestExportEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(isPerRequestExportEnabled()).toBe(expected); + }); + + it.each([ + { value: 'false', expected: false }, + { value: '0', expected: false }, + { value: '', expected: false }, + ])('should return $expected when ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT is "$value"', async ({ value, expected }) => { + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = value; + const { isPerRequestExportEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(isPerRequestExportEnabled()).toBe(expected); + }); + + it('should return false when env var is not set', async () => { + delete process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT; + const { isPerRequestExportEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(isPerRequestExportEnabled()).toBe(false); + }); + }); + + describe('useCustomDomainForObservability', () => { + it.each([ + { value: 'true', expected: true }, + { value: 'TRUE', expected: true }, + { value: '1', expected: true }, + { value: 'yes', expected: true }, + { value: 'on', expected: true }, + ])('should return $expected when A365_OBSERVABILITY_USE_CUSTOM_DOMAIN is "$value"', async ({ value, expected }) => { + process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN = value; + const { useCustomDomainForObservability } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(useCustomDomainForObservability()).toBe(expected); + }); + + it.each([ + { value: 'false', expected: false }, + { value: '0', expected: false }, + { value: '', expected: false }, + ])('should return $expected when A365_OBSERVABILITY_USE_CUSTOM_DOMAIN is "$value"', async ({ value, expected }) => { + process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN = value; + const { useCustomDomainForObservability } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(useCustomDomainForObservability()).toBe(expected); + }); + + it('should return false when env var is not set', async () => { + delete process.env.A365_OBSERVABILITY_USE_CUSTOM_DOMAIN; + const { useCustomDomainForObservability } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(useCustomDomainForObservability()).toBe(false); + }); + }); + + describe('resolveAgent365Endpoint', () => { + it('should return production endpoint for prod cluster', async () => { + const { resolveAgent365Endpoint } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(resolveAgent365Endpoint(ClusterCategory.prod)).toBe('https://agent365.svc.cloud.microsoft'); + }); + + it('should return production endpoint for unknown cluster category', async () => { + const { resolveAgent365Endpoint } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + // Cast to ClusterCategory to test default behavior + expect(resolveAgent365Endpoint('unknown' as never)).toBe('https://agent365.svc.cloud.microsoft'); + }); + + it.each([ + ClusterCategory.local, ClusterCategory.dev, ClusterCategory.test, ClusterCategory.preprod, + ClusterCategory.gov, ClusterCategory.high, ClusterCategory.dod, ClusterCategory.mooncake, + ClusterCategory.ex, ClusterCategory.rx + ])( + 'should return production endpoint for %s cluster (current implementation)', + async (cluster) => { + const { resolveAgent365Endpoint } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + // Current implementation returns prod endpoint for all clusters + expect(resolveAgent365Endpoint(cluster)).toBe('https://agent365.svc.cloud.microsoft'); + } + ); + }); + + describe('getAgent365ObservabilityDomainOverride', () => { + it('should return trimmed URL when env var is set', async () => { + process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://custom.domain.com'; + const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(getAgent365ObservabilityDomainOverride()).toBe('https://custom.domain.com'); + }); + + it('should remove trailing slashes from URL', async () => { + process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://custom.domain.com/'; + const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(getAgent365ObservabilityDomainOverride()).toBe('https://custom.domain.com'); + }); + + it('should remove multiple trailing slashes from URL', async () => { + process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://custom.domain.com///'; + const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(getAgent365ObservabilityDomainOverride()).toBe('https://custom.domain.com'); + }); + + it('should trim whitespace from URL', async () => { + process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = ' https://custom.domain.com '; + const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(getAgent365ObservabilityDomainOverride()).toBe('https://custom.domain.com'); + }); + + it('should return null when env var is not set', async () => { + delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; + const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(getAgent365ObservabilityDomainOverride()).toBeNull(); + }); + + it('should return null when env var is empty string', async () => { + process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = ''; + const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(getAgent365ObservabilityDomainOverride()).toBeNull(); + }); + + it('should return null when env var is whitespace only', async () => { + process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = ' '; + const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + expect(getAgent365ObservabilityDomainOverride()).toBeNull(); + }); + }); + + describe('parseIdentityKey', () => { + it('should parse tenant and agent from key', async () => { + const { parseIdentityKey } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + const result = parseIdentityKey('tenant123:agent456'); + expect(result).toEqual({ tenantId: 'tenant123', agentId: 'agent456' }); + }); + + it('should handle keys with empty values', async () => { + const { parseIdentityKey } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + const result = parseIdentityKey(':agent'); + expect(result).toEqual({ tenantId: '', agentId: 'agent' }); + }); + + it('should handle keys with GUIDs', async () => { + const { parseIdentityKey } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + const result = parseIdentityKey('12345678-1234-1234-1234-123456789abc:98765432-1234-1234-1234-123456789def'); + expect(result).toEqual({ + tenantId: '12345678-1234-1234-1234-123456789abc', + agentId: '98765432-1234-1234-1234-123456789def' + }); + }); + }); + + describe('partitionByIdentity', () => { + const createMockSpan = (name: string, tenantId?: string, agentId?: string): ReadableSpan => ({ + name, + kind: SpanKind.INTERNAL, + spanContext: () => ({ + traceId: '1234567890abcdef1234567890abcdef', + spanId: '1234567890abcdef', + traceFlags: 1 + }), + startTime: [0, 0], + endTime: [1, 0], + ended: true, + status: { code: SpanStatusCode.OK }, + attributes: { + ...(tenantId !== undefined && { 'tenant.id': tenantId }), + ...(agentId !== undefined && { 'gen_ai.agent.id': agentId }), + }, + links: [], + events: [], + duration: [1, 0], + resource: { attributes: {} }, + instrumentationLibrary: { name: 'test' }, + droppedAttributesCount: 0, + droppedEventsCount: 0, + droppedLinksCount: 0, + } as unknown as ReadableSpan); + + it('should group spans by tenant and agent ID', async () => { + const { partitionByIdentity } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + const spans = [ + createMockSpan('span1', 'tenant1', 'agent1'), + createMockSpan('span2', 'tenant1', 'agent1'), + createMockSpan('span3', 'tenant2', 'agent2'), + ]; + + const result = partitionByIdentity(spans); + + expect(result.size).toBe(2); + expect(result.get('tenant1:agent1')?.length).toBe(2); + expect(result.get('tenant2:agent2')?.length).toBe(1); + }); + + it('should skip spans without tenant ID', async () => { + const { partitionByIdentity } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + const spans = [ + createMockSpan('span1', undefined, 'agent1'), + createMockSpan('span2', 'tenant1', 'agent1'), + ]; + + const result = partitionByIdentity(spans); + + expect(result.size).toBe(1); + expect(result.get('tenant1:agent1')?.length).toBe(1); + }); + + it('should skip spans without agent ID', async () => { + const { partitionByIdentity } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + const spans = [ + createMockSpan('span1', 'tenant1', undefined), + createMockSpan('span2', 'tenant1', 'agent1'), + ]; + + const result = partitionByIdentity(spans); + + expect(result.size).toBe(1); + expect(result.get('tenant1:agent1')?.length).toBe(1); + }); + + it('should return empty map for empty spans array', async () => { + const { partitionByIdentity } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + const result = partitionByIdentity([]); + + expect(result.size).toBe(0); + }); + + it('should return empty map when all spans lack identity', async () => { + const { partitionByIdentity } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); + + const spans = [ + createMockSpan('span1', undefined, undefined), + createMockSpan('span2', 'tenant1', undefined), + createMockSpan('span3', undefined, 'agent1'), + ]; + + const result = partitionByIdentity(spans); + + expect(result.size).toBe(0); + }); + }); +}); diff --git a/tests/observability/utils/logging.test.ts b/tests/observability/utils/logging.test.ts new file mode 100644 index 00000000..395a9448 --- /dev/null +++ b/tests/observability/utils/logging.test.ts @@ -0,0 +1,585 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; + +describe('logging', () => { + const originalEnv = process.env; + let consoleLogSpy: ReturnType; + let consoleWarnSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + // Reset modules to allow re-importing with different env vars + jest.resetModules(); + // Clone environment + process.env = { ...originalEnv }; + // Spy on console methods + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + describe('formatError', () => { + it('should format Error objects with message and stack trace', async () => { + const { formatError } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + const error = new Error('Test error message'); + const result = formatError(error); + + expect(result).toContain('Test error message'); + expect(result).toContain('Stack:'); + }); + + it('should format Error objects without stack trace', async () => { + const { formatError } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + const error = new Error('Test error'); + error.stack = undefined; + const result = formatError(error); + + expect(result).toContain('Test error'); + expect(result).toContain('No stack trace'); + }); + + it('should convert non-Error values to string', async () => { + const { formatError } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + expect(formatError('string error')).toBe('string error'); + expect(formatError(123)).toBe('123'); + expect(formatError(null)).toBe('null'); + expect(formatError(undefined)).toBe('undefined'); + expect(formatError({ foo: 'bar' })).toBe('[object Object]'); + }); + }); + + describe('logger with level: none (default)', () => { + it('should not log info messages when level is none', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'none'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test info message'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + + it('should not log warn messages when level is none', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'none'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.warn('Test warn message'); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should not log error messages when level is none', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'none'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.error('Test error message'); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should default to none when env var is not set', async () => { + delete process.env.A365_OBSERVABILITY_LOG_LEVEL; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test message'); + logger.warn('Test message'); + logger.error('Test message'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + }); + + describe('logger with level: info', () => { + it('should log info messages when level is info', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test info message'); + + expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test info message'); + }); + + it('should not log warn messages when level is info only', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.warn('Test warn message'); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should not log error messages when level is info only', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.error('Test error message'); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + }); + + describe('logger with level: warn', () => { + it('should log warn messages when level is warn', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'warn'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.warn('Test warn message'); + + expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Test warn message'); + }); + + it('should not log info messages when level is warn only', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'warn'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test info message'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + }); + + describe('logger with level: error', () => { + it('should log error messages when level is error', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.error('Test error message'); + + expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Test error message'); + }); + + it('should not log info or warn messages when level is error only', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test info message'); + logger.warn('Test warn message'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('logger with multiple levels (pipe-separated)', () => { + it('should log info and warn when level is info|warn', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info|warn'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test info'); + logger.warn('Test warn'); + logger.error('Test error'); + + expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test info'); + expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Test warn'); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should log warn and error when level is warn|error', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'warn|error'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test info'); + logger.warn('Test warn'); + logger.error('Test error'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Test warn'); + expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Test error'); + }); + + it('should log all levels when level is info|warn|error', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info|warn|error'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test info'); + logger.warn('Test warn'); + logger.error('Test error'); + + expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test info'); + expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Test warn'); + expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Test error'); + }); + + it('should handle whitespace in pipe-separated levels', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = ' info | warn | error '; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test info'); + logger.warn('Test warn'); + logger.error('Test error'); + + expect(consoleLogSpy).toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + }); + + describe('logger with case insensitivity', () => { + it('should handle uppercase log levels', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'INFO'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test info'); + + expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test info'); + }); + + it('should handle mixed case log levels', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'InFo|WaRn'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test info'); + logger.warn('Test warn'); + + expect(consoleLogSpy).toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + }); + + describe('logger with invalid values', () => { + it('should default to none for invalid log level', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'invalid'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test message'); + logger.warn('Test message'); + logger.error('Test message'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should default to none for empty string', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = ''; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test message'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + + it('should ignore invalid levels in pipe-separated string', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'invalid|info|alsoinvalid'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test info'); + logger.warn('Test warn'); + + expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test info'); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('logger with additional arguments', () => { + it('should pass additional arguments to console.log', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.info('Test message', { data: 'value' }, 123); + + expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test message', { data: 'value' }, 123); + }); + + it('should pass additional arguments to console.warn', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'warn'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + logger.warn('Test message', ['array', 'data']); + + expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Test message', ['array', 'data']); + }); + + it('should pass additional arguments to console.error', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + const errorObj = new Error('test'); + logger.error('Test message', errorObj); + + expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Test message', errorObj); + }); + }); + + describe('default export', () => { + it('should export logger as default', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; + const loggingModule = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + expect(loggingModule.default).toBe(loggingModule.logger); + }); + }); + + describe('dynamic log level resolution', () => { + it('should support runtime log level changes without module reload', async () => { + // Start with no logging + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'none'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + // Should not log with 'none' + logger.info('Should not appear'); + expect(consoleLogSpy).not.toHaveBeenCalled(); + + // Change log level at runtime + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; + + // Should now log without module reload + logger.info('Should appear now'); + expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Should appear now'); + }); + + it('should support dynamic multi-tenant log level scenarios', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + // Tenant A - error only + logger.info('Tenant A info'); + logger.error('Tenant A error'); + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Tenant A error'); + + consoleErrorSpy.mockClear(); + + // Switch to Tenant B - all logging + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info|warn|error'; + + logger.info('Tenant B info'); + logger.warn('Tenant B warn'); + logger.error('Tenant B error'); + expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Tenant B info'); + expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Tenant B warn'); + expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Tenant B error'); + }); + + it('should evaluate log level on each call', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; + const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + // First call - should log + logger.info('First call'); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + + // Disable logging + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'none'; + + // Second call - should not log + logger.info('Second call'); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); // Still 1, not 2 + + // Re-enable logging + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; + + // Third call - should log again + logger.info('Third call'); + expect(consoleLogSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('setLogger and getLogger', () => { + it('should allow setting a custom logger', async () => { + const { setLogger, getLogger, logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + const customLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + }; + + setLogger(customLogger); + + logger.info('Test message', 'arg1'); + logger.warn('Warning message'); + logger.error('Error message'); + + expect(customLogger.info).toHaveBeenCalledWith('Test message', 'arg1'); + expect(customLogger.warn).toHaveBeenCalledWith('Warning message'); + expect(customLogger.error).toHaveBeenCalledWith('Error message'); + + // getLogger should return the custom logger + expect(getLogger()).toBe(customLogger); + }); + + it('should throw when setting invalid logger (null)', async () => { + const { setLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + expect(() => setLogger(null as never)).toThrow('Custom logger must implement ILogger interface'); + }); + + it('should throw when setting logger missing info method', async () => { + const { setLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + const invalidLogger = { + warn: jest.fn(), + error: jest.fn() + }; + + expect(() => setLogger(invalidLogger as never)).toThrow('Custom logger must implement ILogger interface'); + }); + + it('should throw when setting logger missing warn method', async () => { + const { setLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + const invalidLogger = { + info: jest.fn(), + error: jest.fn() + }; + + expect(() => setLogger(invalidLogger as never)).toThrow('Custom logger must implement ILogger interface'); + }); + + it('should throw when setting logger missing error method', async () => { + const { setLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + const invalidLogger = { + info: jest.fn(), + warn: jest.fn() + }; + + expect(() => setLogger(invalidLogger as never)).toThrow('Custom logger must implement ILogger interface'); + }); + }); + + describe('resetLogger', () => { + it('should reset to default logger', async () => { + process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; + const { setLogger, resetLogger, logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + + const customLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + }; + + setLogger(customLogger); + logger.info('Custom logger call'); + expect(customLogger.info).toHaveBeenCalled(); + + // Reset to default + resetLogger(); + + // Now should use default logger (console) + logger.info('Default logger call'); + expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Default logger call'); + }); + }); + + describe('DefaultLogger with custom configuration provider', () => { + it('should use custom configuration provider for log level resolution', async () => { + const { DefaultLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + const { ObservabilityConfiguration } = await import('@microsoft/agents-a365-observability/src/configuration'); + const { DefaultConfigurationProvider } = await import('@microsoft/agents-a365-runtime'); + + // Create a custom configuration that returns 'info' log level + const customConfig = new ObservabilityConfiguration({ + observabilityLogLevel: () => 'info' + }); + const customProvider = new DefaultConfigurationProvider(() => customConfig); + + // Create DefaultLogger with custom provider + const customLogger = new DefaultLogger(customProvider); + + // Should log info messages based on custom config + customLogger.info('Test info message'); + expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test info message'); + + // Should not log error (since level is 'info' only) + customLogger.error('Test error message'); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should use different log levels for different configuration providers', async () => { + const { DefaultLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + const { ObservabilityConfiguration } = await import('@microsoft/agents-a365-observability/src/configuration'); + const { DefaultConfigurationProvider } = await import('@microsoft/agents-a365-runtime'); + + // Provider 1: info only + const config1 = new ObservabilityConfiguration({ + observabilityLogLevel: () => 'info' + }); + const provider1 = new DefaultConfigurationProvider(() => config1); + const logger1 = new DefaultLogger(provider1); + + // Provider 2: error only + const config2 = new ObservabilityConfiguration({ + observabilityLogLevel: () => 'error' + }); + const provider2 = new DefaultConfigurationProvider(() => config2); + const logger2 = new DefaultLogger(provider2); + + // Logger1 should log info but not error + logger1.info('Logger1 info'); + logger1.error('Logger1 error'); + expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Logger1 info'); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + consoleLogSpy.mockClear(); + + // Logger2 should log error but not info + logger2.info('Logger2 info'); + logger2.error('Logger2 error'); + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Logger2 error'); + }); + + it('should support dynamic log level changes via override function', async () => { + const { DefaultLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + const { ObservabilityConfiguration } = await import('@microsoft/agents-a365-observability/src/configuration'); + const { DefaultConfigurationProvider } = await import('@microsoft/agents-a365-runtime'); + + // Dynamic log level that can be changed at runtime + let currentLogLevel = 'none'; + + const dynamicConfig = new ObservabilityConfiguration({ + observabilityLogLevel: () => currentLogLevel + }); + const dynamicProvider = new DefaultConfigurationProvider(() => dynamicConfig); + const logger = new DefaultLogger(dynamicProvider); + + // Initially none - no logging + logger.info('Should not appear'); + expect(consoleLogSpy).not.toHaveBeenCalled(); + + // Change to info + currentLogLevel = 'info'; + logger.info('Should appear now'); + expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Should appear now'); + + // Change to error only + currentLogLevel = 'error'; + consoleLogSpy.mockClear(); + logger.info('Should not appear'); + logger.error('Error should appear'); + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Error should appear'); + }); + + it('should support pipe-separated log levels via custom config', async () => { + const { DefaultLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); + const { ObservabilityConfiguration } = await import('@microsoft/agents-a365-observability/src/configuration'); + const { DefaultConfigurationProvider } = await import('@microsoft/agents-a365-runtime'); + + const customConfig = new ObservabilityConfiguration({ + observabilityLogLevel: () => 'warn|error' + }); + const customProvider = new DefaultConfigurationProvider(() => customConfig); + const logger = new DefaultLogger(customProvider); + + logger.info('Info message'); + logger.warn('Warn message'); + logger.error('Error message'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Warn message'); + expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Error message'); + }); + }); +}); diff --git a/tests/runtime/agentic-authorization-service.test.ts b/tests/runtime/agentic-authorization-service.test.ts index e8c21f24..723eff44 100644 --- a/tests/runtime/agentic-authorization-service.test.ts +++ b/tests/runtime/agentic-authorization-service.test.ts @@ -3,13 +3,13 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { TurnContext, Authorization } from '@microsoft/agents-hosting'; -import { AgenticAuthenticationService, PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE } from '@microsoft/agents-a365-runtime'; +import { AgenticAuthenticationService } from '@microsoft/agents-a365-runtime'; describe('AgenticAuthenticationService', () => { let mockAuthorization: jest.Mocked; let mockTurnContext: jest.Mocked; const mockAuthHandlerName = 'test-auth-handler'; - const expectedScope = process.env.MCP_PLATFORM_AUTHENTICATION_SCOPE || PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE; + const testScopes = ['test-scope/.default']; beforeEach(() => { mockAuthorization = { @@ -26,14 +26,15 @@ describe('AgenticAuthenticationService', () => { const result = await AgenticAuthenticationService.GetAgenticUserToken( mockAuthorization, mockAuthHandlerName, - mockTurnContext + mockTurnContext, + testScopes ); expect(result).toEqual('exchanged-token-123'); expect(mockAuthorization.exchangeToken).toHaveBeenCalledWith( mockTurnContext, mockAuthHandlerName, - { scopes: [expectedScope] } + { scopes: testScopes } ); }); @@ -43,7 +44,8 @@ describe('AgenticAuthenticationService', () => { const result = await AgenticAuthenticationService.GetAgenticUserToken( mockAuthorization, mockAuthHandlerName, - mockTurnContext + mockTurnContext, + testScopes ); expect(result).toEqual(''); @@ -55,26 +57,28 @@ describe('AgenticAuthenticationService', () => { const result = await AgenticAuthenticationService.GetAgenticUserToken( mockAuthorization, mockAuthHandlerName, - mockTurnContext + mockTurnContext, + testScopes ); expect(result).toEqual(''); }); - it('should use default MCP platform authentication scope', async () => { + it('should pass the provided scopes to exchangeToken', async () => { mockAuthorization.exchangeToken.mockResolvedValue({ token: 'test-token' } as unknown as Awaited>); + const customScopes = ['custom-scope-1/.default', 'custom-scope-2/.default']; await AgenticAuthenticationService.GetAgenticUserToken( mockAuthorization, mockAuthHandlerName, - mockTurnContext + mockTurnContext, + customScopes ); - // Verify the scope used is from getMcpPlatformAuthenticationScope (env var or default) expect(mockAuthorization.exchangeToken).toHaveBeenCalledWith( mockTurnContext, mockAuthHandlerName, - { scopes: [expectedScope] } + { scopes: customScopes } ); }); @@ -85,13 +89,32 @@ describe('AgenticAuthenticationService', () => { await AgenticAuthenticationService.GetAgenticUserToken( mockAuthorization, customAuthHandler, - mockTurnContext + mockTurnContext, + testScopes ); expect(mockAuthorization.exchangeToken).toHaveBeenCalledWith( mockTurnContext, customAuthHandler, - { scopes: [expectedScope] } + { scopes: testScopes } + ); + }); + + it('should use default MCP platform scope when called without scopes (deprecated overload)', async () => { + mockAuthorization.exchangeToken.mockResolvedValue({ token: 'test-token' } as unknown as Awaited>); + + // Call the deprecated 3-parameter overload (no scopes) + await AgenticAuthenticationService.GetAgenticUserToken( + mockAuthorization, + mockAuthHandlerName, + mockTurnContext + ); + + // Verify it uses the default MCP platform authentication scope + expect(mockAuthorization.exchangeToken).toHaveBeenCalledWith( + mockTurnContext, + mockAuthHandlerName, + { scopes: ['ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default'] } ); }); }); diff --git a/tests/runtime/configuration/RuntimeConfiguration.test.ts b/tests/runtime/configuration/RuntimeConfiguration.test.ts new file mode 100644 index 00000000..07bd7863 --- /dev/null +++ b/tests/runtime/configuration/RuntimeConfiguration.test.ts @@ -0,0 +1,359 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + RuntimeConfiguration, + DefaultConfigurationProvider, + defaultRuntimeConfigurationProvider, + ClusterCategory +} from '../../../packages/agents-a365-runtime/src'; + +describe('RuntimeConfiguration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('clusterCategory', () => { + it('should use override function when provided', () => { + const config = new RuntimeConfiguration({ clusterCategory: () => ClusterCategory.gov }); + expect(config.clusterCategory).toBe('gov'); + }); + + it('should fall back to env var when override not provided', () => { + process.env.CLUSTER_CATEGORY = 'dev'; + const config = new RuntimeConfiguration({}); + expect(config.clusterCategory).toBe('dev'); + }); + + it('should fall back to default when neither override nor env var', () => { + delete process.env.CLUSTER_CATEGORY; + const config = new RuntimeConfiguration({}); + expect(config.clusterCategory).toBe('prod'); + }); + + it('should call override function on each access (dynamic resolution)', () => { + let callCount = 0; + const config = new RuntimeConfiguration({ + clusterCategory: () => { + callCount++; + return ClusterCategory.gov; + } + }); + config.clusterCategory; + config.clusterCategory; + expect(callCount).toBe(2); // Called twice, not cached + }); + + it('should support dynamic values from external state', () => { + let currentTenant = 'tenant-a'; + const tenantConfigs: Record = { + 'tenant-a': ClusterCategory.prod, + 'tenant-b': ClusterCategory.gov + }; + const config = new RuntimeConfiguration({ + clusterCategory: () => tenantConfigs[currentTenant] + }); + + expect(config.clusterCategory).toBe('prod'); + currentTenant = 'tenant-b'; + expect(config.clusterCategory).toBe('gov'); // Dynamic! + }); + + it('should lowercase env var value', () => { + process.env.CLUSTER_CATEGORY = 'DEV'; + const config = new RuntimeConfiguration({}); + expect(config.clusterCategory).toBe('dev'); + }); + + it('should return prod for empty string env var', () => { + process.env.CLUSTER_CATEGORY = ''; + const config = new RuntimeConfiguration({}); + expect(config.clusterCategory).toBe('prod'); + }); + + it.each([ + 'local', 'dev', 'test', 'preprod', 'firstrelease', + 'prod', 'gov', 'high', 'dod', 'mooncake', 'ex', 'rx' + ])('should accept valid cluster category: %s', (category) => { + process.env.CLUSTER_CATEGORY = category; + const config = new RuntimeConfiguration({}); + expect(config.clusterCategory).toBe(category); + }); + + it.each([ + 'invalid', 'foobar', 'production', 'development', 'staging', 'INVALID' + ])('should fall back to prod for invalid cluster category: %s', (invalidCategory) => { + process.env.CLUSTER_CATEGORY = invalidCategory; + const config = new RuntimeConfiguration({}); + expect(config.clusterCategory).toBe('prod'); + }); + }); + + describe('isDevelopmentEnvironment', () => { + it('should return true for local cluster', () => { + expect(new RuntimeConfiguration({ clusterCategory: () => ClusterCategory.local }).isDevelopmentEnvironment).toBe(true); + }); + + it('should return true for dev cluster', () => { + expect(new RuntimeConfiguration({ clusterCategory: () => ClusterCategory.dev }).isDevelopmentEnvironment).toBe(true); + }); + + it('should return false for prod cluster', () => { + expect(new RuntimeConfiguration({ clusterCategory: () => ClusterCategory.prod }).isDevelopmentEnvironment).toBe(false); + }); + + it('should return false for test cluster', () => { + expect(new RuntimeConfiguration({ clusterCategory: () => ClusterCategory.test }).isDevelopmentEnvironment).toBe(false); + }); + + it('should return false for gov cluster', () => { + expect(new RuntimeConfiguration({ clusterCategory: () => ClusterCategory.gov }).isDevelopmentEnvironment).toBe(false); + }); + + it('should derive from clusterCategory dynamically', () => { + let currentCluster: ClusterCategory = ClusterCategory.prod; + const config = new RuntimeConfiguration({ + clusterCategory: () => currentCluster + }); + + expect(config.isDevelopmentEnvironment).toBe(false); + currentCluster = ClusterCategory.dev; + expect(config.isDevelopmentEnvironment).toBe(true); + }); + }); + + describe('isNodeEnvDevelopment', () => { + it('should use override function when provided', () => { + const config = new RuntimeConfiguration({ + isNodeEnvDevelopment: () => true + }); + expect(config.isNodeEnvDevelopment).toBe(true); + }); + + it('should return false override when provided', () => { + process.env.NODE_ENV = 'development'; + const config = new RuntimeConfiguration({ + isNodeEnvDevelopment: () => false + }); + // Override takes precedence over NODE_ENV + expect(config.isNodeEnvDevelopment).toBe(false); + }); + + it('should return true when NODE_ENV is development (lowercase)', () => { + process.env.NODE_ENV = 'development'; + const config = new RuntimeConfiguration({}); + expect(config.isNodeEnvDevelopment).toBe(true); + }); + + it('should return true when NODE_ENV is Development (mixed case)', () => { + process.env.NODE_ENV = 'Development'; + const config = new RuntimeConfiguration({}); + expect(config.isNodeEnvDevelopment).toBe(true); + }); + + it('should return true when NODE_ENV is DEVELOPMENT (uppercase)', () => { + process.env.NODE_ENV = 'DEVELOPMENT'; + const config = new RuntimeConfiguration({}); + expect(config.isNodeEnvDevelopment).toBe(true); + }); + + it('should return false when NODE_ENV is production', () => { + process.env.NODE_ENV = 'production'; + const config = new RuntimeConfiguration({}); + expect(config.isNodeEnvDevelopment).toBe(false); + }); + + it('should return false when NODE_ENV is not set', () => { + delete process.env.NODE_ENV; + const config = new RuntimeConfiguration({}); + expect(config.isNodeEnvDevelopment).toBe(false); + }); + + it('should return false when NODE_ENV is empty string', () => { + process.env.NODE_ENV = ''; + const config = new RuntimeConfiguration({}); + expect(config.isNodeEnvDevelopment).toBe(false); + }); + + it('should call override function on each access', () => { + let callCount = 0; + const config = new RuntimeConfiguration({ + isNodeEnvDevelopment: () => { + callCount++; + return true; + } + }); + config.isNodeEnvDevelopment; + config.isNodeEnvDevelopment; + expect(callCount).toBe(2); + }); + + it('should fall back to env var when override returns undefined', () => { + process.env.NODE_ENV = 'development'; + const config = new RuntimeConfiguration({ + isNodeEnvDevelopment: () => undefined as unknown as boolean + }); + expect(config.isNodeEnvDevelopment).toBe(true); // Falls through to env var + }); + + it('should fall back to env var (false) when override returns undefined and NODE_ENV is production', () => { + process.env.NODE_ENV = 'production'; + const config = new RuntimeConfiguration({ + isNodeEnvDevelopment: () => undefined as unknown as boolean + }); + expect(config.isNodeEnvDevelopment).toBe(false); // Falls through to env var + }); + }); + + describe('constructor', () => { + it('should accept no overrides', () => { + delete process.env.CLUSTER_CATEGORY; + const config = new RuntimeConfiguration(); + expect(config.clusterCategory).toBe('prod'); + }); + + it('should accept empty overrides object', () => { + delete process.env.CLUSTER_CATEGORY; + const config = new RuntimeConfiguration({}); + expect(config.clusterCategory).toBe('prod'); + }); + + it('should accept undefined overrides', () => { + delete process.env.CLUSTER_CATEGORY; + const config = new RuntimeConfiguration(undefined); + expect(config.clusterCategory).toBe('prod'); + }); + }); +}); + +describe('DefaultConfigurationProvider', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return the same configuration instance on multiple calls', () => { + const provider = new DefaultConfigurationProvider(() => new RuntimeConfiguration()); + const config1 = provider.getConfiguration(); + const config2 = provider.getConfiguration(); + expect(config1).toBe(config2); + }); + + it('should create configuration using the provided factory', () => { + const provider = new DefaultConfigurationProvider(() => + new RuntimeConfiguration({ clusterCategory: () => ClusterCategory.gov }) + ); + expect(provider.getConfiguration().clusterCategory).toBe('gov'); + }); +}); + +describe('defaultRuntimeConfigurationProvider', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should be an instance of DefaultConfigurationProvider', () => { + expect(defaultRuntimeConfigurationProvider).toBeInstanceOf(DefaultConfigurationProvider); + }); + + it('should return RuntimeConfiguration from getConfiguration', () => { + const config = defaultRuntimeConfigurationProvider.getConfiguration(); + expect(config).toBeInstanceOf(RuntimeConfiguration); + }); +}); + +describe('RuntimeConfiguration static utility methods', () => { + describe('parseEnvBoolean', () => { + it('should return false for undefined', () => { + expect(RuntimeConfiguration.parseEnvBoolean(undefined)).toBe(false); + }); + + it('should return false for empty string', () => { + expect(RuntimeConfiguration.parseEnvBoolean('')).toBe(false); + }); + + it.each(['true', 'TRUE', 'True', 'TrUe'])('should return true for "%s"', (value) => { + expect(RuntimeConfiguration.parseEnvBoolean(value)).toBe(true); + }); + + it.each(['1'])('should return true for "%s"', (value) => { + expect(RuntimeConfiguration.parseEnvBoolean(value)).toBe(true); + }); + + it.each(['yes', 'YES', 'Yes'])('should return true for "%s"', (value) => { + expect(RuntimeConfiguration.parseEnvBoolean(value)).toBe(true); + }); + + it.each(['on', 'ON', 'On'])('should return true for "%s"', (value) => { + expect(RuntimeConfiguration.parseEnvBoolean(value)).toBe(true); + }); + + it.each(['false', 'FALSE', '0', 'no', 'off', 'random', 'anything'])('should return false for "%s"', (value) => { + expect(RuntimeConfiguration.parseEnvBoolean(value)).toBe(false); + }); + }); + + describe('parseEnvInt', () => { + it('should return fallback for undefined', () => { + expect(RuntimeConfiguration.parseEnvInt(undefined, 42)).toBe(42); + }); + + it('should return fallback for empty string', () => { + expect(RuntimeConfiguration.parseEnvInt('', 42)).toBe(42); + }); + + it('should parse valid integer string', () => { + expect(RuntimeConfiguration.parseEnvInt('123', 0)).toBe(123); + }); + + it('should parse negative integer', () => { + expect(RuntimeConfiguration.parseEnvInt('-456', 0)).toBe(-456); + }); + + it('should parse zero', () => { + expect(RuntimeConfiguration.parseEnvInt('0', 42)).toBe(0); + }); + + it('should return fallback for non-numeric string', () => { + expect(RuntimeConfiguration.parseEnvInt('abc', 42)).toBe(42); + }); + + it('should return fallback for NaN result', () => { + expect(RuntimeConfiguration.parseEnvInt('not-a-number', 99)).toBe(99); + }); + + it('should truncate decimal values (parseInt behavior)', () => { + expect(RuntimeConfiguration.parseEnvInt('3.14', 0)).toBe(3); + }); + + it('should parse string with leading zeros', () => { + expect(RuntimeConfiguration.parseEnvInt('007', 0)).toBe(7); + }); + + it('should return fallback for Infinity', () => { + expect(RuntimeConfiguration.parseEnvInt('Infinity', 42)).toBe(42); + }); + + it('should handle large integers', () => { + expect(RuntimeConfiguration.parseEnvInt('1000000', 0)).toBe(1000000); + }); + }); +}); diff --git a/tests/runtime/environment-utils.test.ts b/tests/runtime/environment-utils.test.ts index 512b22c2..96725ff6 100644 --- a/tests/runtime/environment-utils.test.ts +++ b/tests/runtime/environment-utils.test.ts @@ -22,37 +22,23 @@ describe('environment-utils', () => { process.env = originalEnv; }); - describe('getObservabilityAuthenticationScope', () => { - it('should return production observability scope when override is not set', () => { - delete process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE; - + describe('getObservabilityAuthenticationScope (deprecated)', () => { + it('should always return production observability scope (hardcoded default)', () => { + // This function is deprecated and now returns a hardcoded default + // Use ObservabilityConfiguration for env var support const scopes = getObservabilityAuthenticationScope(); expect(scopes).toEqual([PROD_OBSERVABILITY_SCOPE]); expect(scopes[0]).toEqual('https://api.powerplatform.com/.default'); }); - it('should return overridden observability scope when A365_OBSERVABILITY_SCOPES_OVERRIDE is set', () => { + it('should ignore A365_OBSERVABILITY_SCOPES_OVERRIDE env var (deprecated behavior)', () => { + // The deprecated function no longer reads from env vars process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE = 'https://override.example.com/.default'; const scopes = getObservabilityAuthenticationScope(); - expect(scopes).toEqual(['https://override.example.com/.default']); - }); - - it('should support multiple scopes separated by whitespace', () => { - process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE = 'scope-one/.default scope-two/.default'; - - const scopes = getObservabilityAuthenticationScope(); - - expect(scopes).toEqual(['scope-one/.default', 'scope-two/.default']); - }); - - it('should fall back to production scope when override is empty or whitespace', () => { - process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE = ' '; - - const scopes = getObservabilityAuthenticationScope(); - + // Should still return the hardcoded default, not the env var value expect(scopes).toEqual([PROD_OBSERVABILITY_SCOPE]); }); }); @@ -64,50 +50,63 @@ describe('environment-utils', () => { expect(getClusterCategory()).toEqual('prod'); }); - it('should return lowercase cluster category from environment', () => { - process.env.CLUSTER_CATEGORY = 'DEV'; + it('should return the configured cluster category from environment variable', () => { + process.env.CLUSTER_CATEGORY = 'dev'; expect(getClusterCategory()).toEqual('dev'); }); + it('should convert cluster category to lowercase', () => { + process.env.CLUSTER_CATEGORY = 'GOV'; + + expect(getClusterCategory()).toEqual('gov'); + }); + + it('should return prod when CLUSTER_CATEGORY is empty string', () => { + process.env.CLUSTER_CATEGORY = ''; + + expect(getClusterCategory()).toEqual('prod'); + }); + it.each([ - { input: 'local', expected: 'local' }, - { input: 'dev', expected: 'dev' }, - { input: 'test', expected: 'test' }, - { input: 'PROD', expected: 'prod' }, - { input: 'Gov', expected: 'gov' }, - ])('should return $expected for input $input', ({ input, expected }) => { - process.env.CLUSTER_CATEGORY = input; - - expect(getClusterCategory()).toEqual(expected); + 'local', + 'dev', + 'test', + 'preprod', + 'firstrelease', + 'prod', + 'gov', + 'high', + 'dod', + 'mooncake', + 'ex', + 'rx', + ])('should return valid cluster category: %s', (category) => { + process.env.CLUSTER_CATEGORY = category; + + expect(getClusterCategory()).toEqual(category); }); }); describe('isDevelopmentEnvironment', () => { - it('should return true for local cluster', () => { + it('should return true when cluster category is local', () => { process.env.CLUSTER_CATEGORY = 'local'; expect(isDevelopmentEnvironment()).toBe(true); }); - it('should return true for dev cluster', () => { + it('should return true when cluster category is dev', () => { process.env.CLUSTER_CATEGORY = 'dev'; expect(isDevelopmentEnvironment()).toBe(true); }); - it('should return false for prod cluster', () => { + it('should return false when cluster category is prod', () => { process.env.CLUSTER_CATEGORY = 'prod'; expect(isDevelopmentEnvironment()).toBe(false); }); - it('should return false for test cluster', () => { - process.env.CLUSTER_CATEGORY = 'test'; - - expect(isDevelopmentEnvironment()).toBe(false); - }); - it('should return false when no cluster category set', () => { delete process.env.CLUSTER_CATEGORY; @@ -126,25 +125,34 @@ describe('environment-utils', () => { expect(isDevelopmentEnvironment()).toBe(expected); }); + + it.each([ + { cluster: 'LOCAL', expected: true }, + { cluster: 'DEV', expected: true }, + { cluster: 'Local', expected: true }, + { cluster: 'Dev', expected: true }, + ])('should handle uppercase cluster category: $cluster returns $expected', ({ cluster, expected }) => { + process.env.CLUSTER_CATEGORY = cluster; + + expect(isDevelopmentEnvironment()).toBe(expected); + }); }); - describe('getMcpPlatformAuthenticationScope', () => { - it('should return production scope when environment variable is not set', () => { + describe('getMcpPlatformAuthenticationScope (deprecated)', () => { + it('should always return production scope (hardcoded default)', () => { + // This function is deprecated and now returns a hardcoded default + // Use ToolingConfiguration for env var support delete process.env.MCP_PLATFORM_AUTHENTICATION_SCOPE; expect(getMcpPlatformAuthenticationScope()).toEqual(PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE); expect(getMcpPlatformAuthenticationScope()).toEqual('ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default'); }); - it('should return custom scope from environment variable', () => { + it('should ignore MCP_PLATFORM_AUTHENTICATION_SCOPE env var (deprecated behavior)', () => { + // The deprecated function no longer reads from env vars process.env.MCP_PLATFORM_AUTHENTICATION_SCOPE = 'custom-scope/.default'; - expect(getMcpPlatformAuthenticationScope()).toEqual('custom-scope/.default'); - }); - - it('should return empty string if environment variable is empty', () => { - process.env.MCP_PLATFORM_AUTHENTICATION_SCOPE = ''; - + // Should still return the hardcoded default, not the env var value expect(getMcpPlatformAuthenticationScope()).toEqual(PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE); }); }); diff --git a/tests/runtime/power-platform-api-discovery.test.ts b/tests/runtime/power-platform-api-discovery.test.ts index 2f67164f..d727f713 100644 --- a/tests/runtime/power-platform-api-discovery.test.ts +++ b/tests/runtime/power-platform-api-discovery.test.ts @@ -8,48 +8,48 @@ const testTenantId = 'e3064512-cc6d-4703-be71-a2ecaecaa98a'; // Test data - all cluster configurations in one place const clusterTestData: Array<{ cluster: ClusterCategory; audience: string; host: string }> = [ - { cluster: 'local', audience: 'https://api.powerplatform.localhost', host: 'api.powerplatform.localhost' }, - { cluster: 'dev', audience: 'https://api.powerplatform.com', host: 'api.powerplatform.com' }, - { cluster: 'test', audience: 'https://api.powerplatform.com', host: 'api.powerplatform.com' }, - { cluster: 'preprod', audience: 'https://api.powerplatform.com', host: 'api.powerplatform.com' }, - { cluster: 'firstrelease', audience: 'https://api.powerplatform.com', host: 'api.powerplatform.com' }, - { cluster: 'prod', audience: 'https://api.powerplatform.com', host: 'api.powerplatform.com' }, - { cluster: 'gov', audience: 'https://api.gov.powerplatform.microsoft.us', host: 'api.gov.powerplatform.microsoft.us' }, - { cluster: 'high', audience: 'https://api.high.powerplatform.microsoft.us', host: 'api.high.powerplatform.microsoft.us' }, - { cluster: 'dod', audience: 'https://api.appsplatform.us', host: 'api.appsplatform.us' }, - { cluster: 'mooncake', audience: 'https://api.powerplatform.partner.microsoftonline.cn', host: 'api.powerplatform.partner.microsoftonline.cn' }, - { cluster: 'ex', audience: 'https://api.powerplatform.eaglex.ic.gov', host: 'api.powerplatform.eaglex.ic.gov' }, - { cluster: 'rx', audience: 'https://api.powerplatform.microsoft.scloud', host: 'api.powerplatform.microsoft.scloud' }, + { cluster: ClusterCategory.local, audience: 'https://api.powerplatform.localhost', host: 'api.powerplatform.localhost' }, + { cluster: ClusterCategory.dev, audience: 'https://api.powerplatform.com', host: 'api.powerplatform.com' }, + { cluster: ClusterCategory.test, audience: 'https://api.powerplatform.com', host: 'api.powerplatform.com' }, + { cluster: ClusterCategory.preprod, audience: 'https://api.powerplatform.com', host: 'api.powerplatform.com' }, + { cluster: ClusterCategory.firstrelease, audience: 'https://api.powerplatform.com', host: 'api.powerplatform.com' }, + { cluster: ClusterCategory.prod, audience: 'https://api.powerplatform.com', host: 'api.powerplatform.com' }, + { cluster: ClusterCategory.gov, audience: 'https://api.gov.powerplatform.microsoft.us', host: 'api.gov.powerplatform.microsoft.us' }, + { cluster: ClusterCategory.high, audience: 'https://api.high.powerplatform.microsoft.us', host: 'api.high.powerplatform.microsoft.us' }, + { cluster: ClusterCategory.dod, audience: 'https://api.appsplatform.us', host: 'api.appsplatform.us' }, + { cluster: ClusterCategory.mooncake, audience: 'https://api.powerplatform.partner.microsoftonline.cn', host: 'api.powerplatform.partner.microsoftonline.cn' }, + { cluster: ClusterCategory.ex, audience: 'https://api.powerplatform.eaglex.ic.gov', host: 'api.powerplatform.eaglex.ic.gov' }, + { cluster: ClusterCategory.rx, audience: 'https://api.powerplatform.microsoft.scloud', host: 'api.powerplatform.microsoft.scloud' }, ]; const tenantEndpointTestData: Array<{ cluster: ClusterCategory; endpoint: string }> = [ - { cluster: 'local', endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.localhost' }, - { cluster: 'dev', endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com' }, - { cluster: 'test', endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com' }, - { cluster: 'preprod', endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com' }, - { cluster: 'firstrelease', endpoint: 'e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com' }, - { cluster: 'prod', endpoint: 'e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com' }, - { cluster: 'gov', endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.gov.powerplatform.microsoft.us' }, - { cluster: 'high', endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.high.powerplatform.microsoft.us' }, - { cluster: 'dod', endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.appsplatform.us' }, - { cluster: 'mooncake', endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.partner.microsoftonline.cn' }, - { cluster: 'ex', endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.eaglex.ic.gov' }, - { cluster: 'rx', endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.microsoft.scloud' }, + { cluster: ClusterCategory.local, endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.localhost' }, + { cluster: ClusterCategory.dev, endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com' }, + { cluster: ClusterCategory.test, endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com' }, + { cluster: ClusterCategory.preprod, endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com' }, + { cluster: ClusterCategory.firstrelease, endpoint: 'e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com' }, + { cluster: ClusterCategory.prod, endpoint: 'e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com' }, + { cluster: ClusterCategory.gov, endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.gov.powerplatform.microsoft.us' }, + { cluster: ClusterCategory.high, endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.high.powerplatform.microsoft.us' }, + { cluster: ClusterCategory.dod, endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.appsplatform.us' }, + { cluster: ClusterCategory.mooncake, endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.partner.microsoftonline.cn' }, + { cluster: ClusterCategory.ex, endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.eaglex.ic.gov' }, + { cluster: ClusterCategory.rx, endpoint: 'e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.microsoft.scloud' }, ]; const tenantIslandEndpointTestData: Array<{ cluster: ClusterCategory; endpoint: string }> = [ - { cluster: 'local', endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.localhost' }, - { cluster: 'dev', endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com' }, - { cluster: 'test', endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com' }, - { cluster: 'preprod', endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com' }, - { cluster: 'firstrelease', endpoint: 'il-e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com' }, - { cluster: 'prod', endpoint: 'il-e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com' }, - { cluster: 'gov', endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.gov.powerplatform.microsoft.us' }, - { cluster: 'high', endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.high.powerplatform.microsoft.us' }, - { cluster: 'dod', endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.appsplatform.us' }, - { cluster: 'mooncake', endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.partner.microsoftonline.cn' }, - { cluster: 'ex', endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.eaglex.ic.gov' }, - { cluster: 'rx', endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.microsoft.scloud' }, + { cluster: ClusterCategory.local, endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.localhost' }, + { cluster: ClusterCategory.dev, endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com' }, + { cluster: ClusterCategory.test, endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com' }, + { cluster: ClusterCategory.preprod, endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.com' }, + { cluster: ClusterCategory.firstrelease, endpoint: 'il-e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com' }, + { cluster: ClusterCategory.prod, endpoint: 'il-e3064512cc6d4703be71a2ecaecaa9.8a.tenant.api.powerplatform.com' }, + { cluster: ClusterCategory.gov, endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.gov.powerplatform.microsoft.us' }, + { cluster: ClusterCategory.high, endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.high.powerplatform.microsoft.us' }, + { cluster: ClusterCategory.dod, endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.appsplatform.us' }, + { cluster: ClusterCategory.mooncake, endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.partner.microsoftonline.cn' }, + { cluster: ClusterCategory.ex, endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.eaglex.ic.gov' }, + { cluster: ClusterCategory.rx, endpoint: 'il-e3064512cc6d4703be71a2ecaecaa98.a.tenant.api.powerplatform.microsoft.scloud' }, ]; describe('PowerPlatformApiDiscovery', () => { @@ -80,17 +80,17 @@ describe('PowerPlatformApiDiscovery', () => { ); it('should reject tenant ids with invalid host name characters', () => { - expect(() => new PowerPlatformApiDiscovery('local').getTenantEndpoint('invalid?')).toThrow( + expect(() => new PowerPlatformApiDiscovery(ClusterCategory.local).getTenantEndpoint('invalid?')).toThrow( 'Cannot generate Power Platform API endpoint because the tenant identifier contains invalid host name characters, only alphanumeric and dash characters are expected: invalid?' ); }); describe('should reject tenant ids of insufficient length', () => { it.each<{ tenantId: string; cluster: ClusterCategory; minLength: number; normalized: string }>([ - { tenantId: 'a', cluster: 'local', minLength: 2, normalized: 'a' }, - { tenantId: 'a-', cluster: 'local', minLength: 2, normalized: 'a' }, - { tenantId: 'aa', cluster: 'prod', minLength: 3, normalized: 'aa' }, - { tenantId: 'a-a', cluster: 'prod', minLength: 3, normalized: 'aa' }, + { tenantId: 'a', cluster: ClusterCategory.local, minLength: 2, normalized: 'a' }, + { tenantId: 'a-', cluster: ClusterCategory.local, minLength: 2, normalized: 'a' }, + { tenantId: 'aa', cluster: ClusterCategory.prod, minLength: 3, normalized: 'aa' }, + { tenantId: 'a-a', cluster: ClusterCategory.prod, minLength: 3, normalized: 'aa' }, ])( 'should throw error for tenantId "$tenantId" in $cluster cluster', ({ tenantId, cluster, minLength, normalized }) => { @@ -111,7 +111,7 @@ describe('PowerPlatformApiDiscovery', () => { ); it('should reject tenant ids with invalid host name characters', () => { - expect(() => new PowerPlatformApiDiscovery('local').getTenantIslandClusterEndpoint('invalid?')).toThrow( + expect(() => new PowerPlatformApiDiscovery(ClusterCategory.local).getTenantIslandClusterEndpoint('invalid?')).toThrow( 'Cannot generate Power Platform API endpoint because the tenant identifier contains invalid host name characters, only alphanumeric and dash characters are expected: invalid?' ); }); diff --git a/tests/tooling-extensions-claude/configuration/ClaudeToolingConfiguration.test.ts b/tests/tooling-extensions-claude/configuration/ClaudeToolingConfiguration.test.ts new file mode 100644 index 00000000..43ab2413 --- /dev/null +++ b/tests/tooling-extensions-claude/configuration/ClaudeToolingConfiguration.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + ClaudeToolingConfiguration, + defaultClaudeToolingConfigurationProvider +} from '../../../packages/agents-a365-tooling-extensions-claude/src'; +import { ToolingConfiguration } from '../../../packages/agents-a365-tooling/src'; +import { RuntimeConfiguration, DefaultConfigurationProvider, ClusterCategory } from '../../../packages/agents-a365-runtime/src'; + +describe('ClaudeToolingConfiguration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('inheritance from ToolingConfiguration', () => { + it('should inherit tooling settings', () => { + const config = new ClaudeToolingConfiguration({ + mcpPlatformEndpoint: () => 'https://custom.endpoint' + }); + expect(config.mcpPlatformEndpoint).toBe('https://custom.endpoint'); + }); + + it('should be instanceof ToolingConfiguration', () => { + const config = new ClaudeToolingConfiguration(); + expect(config).toBeInstanceOf(ToolingConfiguration); + }); + + it('should be instanceof RuntimeConfiguration', () => { + const config = new ClaudeToolingConfiguration(); + expect(config).toBeInstanceOf(RuntimeConfiguration); + }); + }); + + describe('inherited runtime settings', () => { + it('should inherit clusterCategory from override', () => { + const config = new ClaudeToolingConfiguration({ clusterCategory: () => ClusterCategory.gov }); + expect(config.clusterCategory).toBe(ClusterCategory.gov); + }); + + it('should inherit clusterCategory from env var', () => { + process.env.CLUSTER_CATEGORY = 'dev'; + const config = new ClaudeToolingConfiguration({}); + expect(config.clusterCategory).toBe('dev'); + }); + + it('should inherit isDevelopmentEnvironment', () => { + const config = new ClaudeToolingConfiguration({ clusterCategory: () => ClusterCategory.local }); + expect(config.isDevelopmentEnvironment).toBe(true); + }); + }); + + describe('inherited tooling settings', () => { + it('should inherit mcpPlatformEndpoint from env var', () => { + process.env.MCP_PLATFORM_ENDPOINT = 'https://env.endpoint'; + const config = new ClaudeToolingConfiguration({}); + expect(config.mcpPlatformEndpoint).toBe('https://env.endpoint'); + }); + + it('should inherit mcpPlatformAuthenticationScope from override', () => { + const config = new ClaudeToolingConfiguration({ + mcpPlatformAuthenticationScope: () => 'custom-scope/.default' + }); + expect(config.mcpPlatformAuthenticationScope).toBe('custom-scope/.default'); + }); + + it('should use default mcpPlatformEndpoint when not overridden', () => { + delete process.env.MCP_PLATFORM_ENDPOINT; + const config = new ClaudeToolingConfiguration({}); + expect(config.mcpPlatformEndpoint).toBe('https://agent365.svc.cloud.microsoft'); + }); + }); + + describe('combined overrides', () => { + it('should allow overriding runtime, tooling settings together', () => { + const config = new ClaudeToolingConfiguration({ + clusterCategory: () => ClusterCategory.dev, + mcpPlatformEndpoint: () => 'https://dev.endpoint', + mcpPlatformAuthenticationScope: () => 'dev-scope/.default' + }); + expect(config.clusterCategory).toBe(ClusterCategory.dev); + expect(config.isDevelopmentEnvironment).toBe(true); + expect(config.mcpPlatformEndpoint).toBe('https://dev.endpoint'); + expect(config.mcpPlatformAuthenticationScope).toBe('dev-scope/.default'); + }); + + it('should support dynamic per-tenant configuration', () => { + let currentTenant = 'tenant-a'; + const tenantEndpoints: Record = { + 'tenant-a': 'https://tenant-a.claude.endpoint', + 'tenant-b': 'https://tenant-b.claude.endpoint' + }; + + const config = new ClaudeToolingConfiguration({ + mcpPlatformEndpoint: () => tenantEndpoints[currentTenant] + }); + + expect(config.mcpPlatformEndpoint).toBe('https://tenant-a.claude.endpoint'); + currentTenant = 'tenant-b'; + expect(config.mcpPlatformEndpoint).toBe('https://tenant-b.claude.endpoint'); + }); + }); + + describe('constructor', () => { + it('should accept no overrides', () => { + const config = new ClaudeToolingConfiguration(); + expect(config).toBeInstanceOf(ClaudeToolingConfiguration); + }); + + it('should accept empty overrides object', () => { + const config = new ClaudeToolingConfiguration({}); + expect(config).toBeInstanceOf(ClaudeToolingConfiguration); + }); + + it('should accept undefined overrides', () => { + const config = new ClaudeToolingConfiguration(undefined); + expect(config).toBeInstanceOf(ClaudeToolingConfiguration); + }); + }); +}); + +describe('defaultClaudeToolingConfigurationProvider', () => { + it('should be an instance of DefaultConfigurationProvider', () => { + expect(defaultClaudeToolingConfigurationProvider).toBeInstanceOf(DefaultConfigurationProvider); + }); + + it('should return ClaudeToolingConfiguration from getConfiguration', () => { + const config = defaultClaudeToolingConfigurationProvider.getConfiguration(); + expect(config).toBeInstanceOf(ClaudeToolingConfiguration); + }); + + it('should return the same configuration instance on multiple calls', () => { + const config1 = defaultClaudeToolingConfigurationProvider.getConfiguration(); + const config2 = defaultClaudeToolingConfigurationProvider.getConfiguration(); + expect(config1).toBe(config2); + }); +}); diff --git a/tests/tooling-extensions-langchain/configuration/LangChainToolingConfiguration.test.ts b/tests/tooling-extensions-langchain/configuration/LangChainToolingConfiguration.test.ts new file mode 100644 index 00000000..1adf9ff9 --- /dev/null +++ b/tests/tooling-extensions-langchain/configuration/LangChainToolingConfiguration.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + LangChainToolingConfiguration, + defaultLangChainToolingConfigurationProvider +} from '../../../packages/agents-a365-tooling-extensions-langchain/src'; +import { ToolingConfiguration } from '../../../packages/agents-a365-tooling/src'; +import { RuntimeConfiguration, DefaultConfigurationProvider, ClusterCategory } from '../../../packages/agents-a365-runtime/src'; + +describe('LangChainToolingConfiguration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('inheritance from ToolingConfiguration', () => { + it('should inherit tooling settings', () => { + const config = new LangChainToolingConfiguration({ + mcpPlatformEndpoint: () => 'https://custom.endpoint' + }); + expect(config.mcpPlatformEndpoint).toBe('https://custom.endpoint'); + }); + + it('should be instanceof ToolingConfiguration', () => { + const config = new LangChainToolingConfiguration(); + expect(config).toBeInstanceOf(ToolingConfiguration); + }); + + it('should be instanceof RuntimeConfiguration', () => { + const config = new LangChainToolingConfiguration(); + expect(config).toBeInstanceOf(RuntimeConfiguration); + }); + }); + + describe('inherited runtime settings', () => { + it('should inherit clusterCategory from override', () => { + const config = new LangChainToolingConfiguration({ clusterCategory: () => ClusterCategory.gov }); + expect(config.clusterCategory).toBe(ClusterCategory.gov); + }); + + it('should inherit clusterCategory from env var', () => { + process.env.CLUSTER_CATEGORY = 'dev'; + const config = new LangChainToolingConfiguration({}); + expect(config.clusterCategory).toBe('dev'); + }); + + it('should inherit isDevelopmentEnvironment', () => { + const config = new LangChainToolingConfiguration({ clusterCategory: () => ClusterCategory.local }); + expect(config.isDevelopmentEnvironment).toBe(true); + }); + }); + + describe('inherited tooling settings', () => { + it('should inherit mcpPlatformEndpoint from env var', () => { + process.env.MCP_PLATFORM_ENDPOINT = 'https://env.endpoint'; + const config = new LangChainToolingConfiguration({}); + expect(config.mcpPlatformEndpoint).toBe('https://env.endpoint'); + }); + + it('should inherit mcpPlatformAuthenticationScope from override', () => { + const config = new LangChainToolingConfiguration({ + mcpPlatformAuthenticationScope: () => 'custom-scope/.default' + }); + expect(config.mcpPlatformAuthenticationScope).toBe('custom-scope/.default'); + }); + + it('should use default mcpPlatformEndpoint when not overridden', () => { + delete process.env.MCP_PLATFORM_ENDPOINT; + const config = new LangChainToolingConfiguration({}); + expect(config.mcpPlatformEndpoint).toBe('https://agent365.svc.cloud.microsoft'); + }); + }); + + describe('combined overrides', () => { + it('should allow overriding runtime, tooling settings together', () => { + const config = new LangChainToolingConfiguration({ + clusterCategory: () => ClusterCategory.dev, + mcpPlatformEndpoint: () => 'https://dev.endpoint', + mcpPlatformAuthenticationScope: () => 'dev-scope/.default' + }); + expect(config.clusterCategory).toBe(ClusterCategory.dev); + expect(config.isDevelopmentEnvironment).toBe(true); + expect(config.mcpPlatformEndpoint).toBe('https://dev.endpoint'); + expect(config.mcpPlatformAuthenticationScope).toBe('dev-scope/.default'); + }); + + it('should support dynamic per-tenant configuration', () => { + let currentTenant = 'tenant-a'; + const tenantEndpoints: Record = { + 'tenant-a': 'https://tenant-a.langchain.endpoint', + 'tenant-b': 'https://tenant-b.langchain.endpoint' + }; + + const config = new LangChainToolingConfiguration({ + mcpPlatformEndpoint: () => tenantEndpoints[currentTenant] + }); + + expect(config.mcpPlatformEndpoint).toBe('https://tenant-a.langchain.endpoint'); + currentTenant = 'tenant-b'; + expect(config.mcpPlatformEndpoint).toBe('https://tenant-b.langchain.endpoint'); + }); + }); + + describe('constructor', () => { + it('should accept no overrides', () => { + const config = new LangChainToolingConfiguration(); + expect(config).toBeInstanceOf(LangChainToolingConfiguration); + }); + + it('should accept empty overrides object', () => { + const config = new LangChainToolingConfiguration({}); + expect(config).toBeInstanceOf(LangChainToolingConfiguration); + }); + + it('should accept undefined overrides', () => { + const config = new LangChainToolingConfiguration(undefined); + expect(config).toBeInstanceOf(LangChainToolingConfiguration); + }); + }); +}); + +describe('defaultLangChainToolingConfigurationProvider', () => { + it('should be an instance of DefaultConfigurationProvider', () => { + expect(defaultLangChainToolingConfigurationProvider).toBeInstanceOf(DefaultConfigurationProvider); + }); + + it('should return LangChainToolingConfiguration from getConfiguration', () => { + const config = defaultLangChainToolingConfigurationProvider.getConfiguration(); + expect(config).toBeInstanceOf(LangChainToolingConfiguration); + }); + + it('should return the same configuration instance on multiple calls', () => { + const config1 = defaultLangChainToolingConfigurationProvider.getConfiguration(); + const config2 = defaultLangChainToolingConfigurationProvider.getConfiguration(); + expect(config1).toBe(config2); + }); +}); diff --git a/tests/tooling-extensions-openai/configuration/OpenAIToolingConfiguration.test.ts b/tests/tooling-extensions-openai/configuration/OpenAIToolingConfiguration.test.ts new file mode 100644 index 00000000..00bea6da --- /dev/null +++ b/tests/tooling-extensions-openai/configuration/OpenAIToolingConfiguration.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + OpenAIToolingConfiguration, + defaultOpenAIToolingConfigurationProvider +} from '../../../packages/agents-a365-tooling-extensions-openai/src'; +import { ToolingConfiguration } from '../../../packages/agents-a365-tooling/src'; +import { RuntimeConfiguration, DefaultConfigurationProvider, ClusterCategory } from '../../../packages/agents-a365-runtime/src'; + +describe('OpenAIToolingConfiguration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('inheritance from ToolingConfiguration', () => { + it('should inherit tooling settings', () => { + const config = new OpenAIToolingConfiguration({ + mcpPlatformEndpoint: () => 'https://custom.endpoint' + }); + expect(config.mcpPlatformEndpoint).toBe('https://custom.endpoint'); + }); + + it('should be instanceof ToolingConfiguration', () => { + const config = new OpenAIToolingConfiguration(); + expect(config).toBeInstanceOf(ToolingConfiguration); + }); + + it('should be instanceof RuntimeConfiguration', () => { + const config = new OpenAIToolingConfiguration(); + expect(config).toBeInstanceOf(RuntimeConfiguration); + }); + }); + + describe('inherited runtime settings', () => { + it('should inherit clusterCategory from override', () => { + const config = new OpenAIToolingConfiguration({ clusterCategory: () => ClusterCategory.gov }); + expect(config.clusterCategory).toBe(ClusterCategory.gov); + }); + + it('should inherit clusterCategory from env var', () => { + process.env.CLUSTER_CATEGORY = 'dev'; + const config = new OpenAIToolingConfiguration({}); + expect(config.clusterCategory).toBe('dev'); + }); + + it('should inherit isDevelopmentEnvironment', () => { + const config = new OpenAIToolingConfiguration({ clusterCategory: () => ClusterCategory.local }); + expect(config.isDevelopmentEnvironment).toBe(true); + }); + }); + + describe('inherited tooling settings', () => { + it('should inherit mcpPlatformEndpoint from env var', () => { + process.env.MCP_PLATFORM_ENDPOINT = 'https://env.endpoint'; + const config = new OpenAIToolingConfiguration({}); + expect(config.mcpPlatformEndpoint).toBe('https://env.endpoint'); + }); + + it('should inherit mcpPlatformAuthenticationScope from override', () => { + const config = new OpenAIToolingConfiguration({ + mcpPlatformAuthenticationScope: () => 'custom-scope/.default' + }); + expect(config.mcpPlatformAuthenticationScope).toBe('custom-scope/.default'); + }); + + it('should use default mcpPlatformEndpoint when not overridden', () => { + delete process.env.MCP_PLATFORM_ENDPOINT; + const config = new OpenAIToolingConfiguration({}); + expect(config.mcpPlatformEndpoint).toBe('https://agent365.svc.cloud.microsoft'); + }); + }); + + describe('combined overrides', () => { + it('should allow overriding runtime, tooling settings together', () => { + const config = new OpenAIToolingConfiguration({ + clusterCategory: () => ClusterCategory.dev, + mcpPlatformEndpoint: () => 'https://dev.endpoint', + mcpPlatformAuthenticationScope: () => 'dev-scope/.default' + }); + expect(config.clusterCategory).toBe(ClusterCategory.dev); + expect(config.isDevelopmentEnvironment).toBe(true); + expect(config.mcpPlatformEndpoint).toBe('https://dev.endpoint'); + expect(config.mcpPlatformAuthenticationScope).toBe('dev-scope/.default'); + }); + + it('should support dynamic per-tenant configuration', () => { + let currentTenant = 'tenant-a'; + const tenantEndpoints: Record = { + 'tenant-a': 'https://tenant-a.openai.endpoint', + 'tenant-b': 'https://tenant-b.openai.endpoint' + }; + + const config = new OpenAIToolingConfiguration({ + mcpPlatformEndpoint: () => tenantEndpoints[currentTenant] + }); + + expect(config.mcpPlatformEndpoint).toBe('https://tenant-a.openai.endpoint'); + currentTenant = 'tenant-b'; + expect(config.mcpPlatformEndpoint).toBe('https://tenant-b.openai.endpoint'); + }); + }); + + describe('constructor', () => { + it('should accept no overrides', () => { + const config = new OpenAIToolingConfiguration(); + expect(config).toBeInstanceOf(OpenAIToolingConfiguration); + }); + + it('should accept empty overrides object', () => { + const config = new OpenAIToolingConfiguration({}); + expect(config).toBeInstanceOf(OpenAIToolingConfiguration); + }); + + it('should accept undefined overrides', () => { + const config = new OpenAIToolingConfiguration(undefined); + expect(config).toBeInstanceOf(OpenAIToolingConfiguration); + }); + }); +}); + +describe('defaultOpenAIToolingConfigurationProvider', () => { + it('should be an instance of DefaultConfigurationProvider', () => { + expect(defaultOpenAIToolingConfigurationProvider).toBeInstanceOf(DefaultConfigurationProvider); + }); + + it('should return OpenAIToolingConfiguration from getConfiguration', () => { + const config = defaultOpenAIToolingConfigurationProvider.getConfiguration(); + expect(config).toBeInstanceOf(OpenAIToolingConfiguration); + }); + + it('should return the same configuration instance on multiple calls', () => { + const config1 = defaultOpenAIToolingConfigurationProvider.getConfiguration(); + const config2 = defaultOpenAIToolingConfigurationProvider.getConfiguration(); + expect(config1).toBe(config2); + }); +}); diff --git a/tests/tooling/McpToolServerConfigurationService.test.ts b/tests/tooling/McpToolServerConfigurationService.test.ts index 1ae70d04..5ab9ce10 100644 --- a/tests/tooling/McpToolServerConfigurationService.test.ts +++ b/tests/tooling/McpToolServerConfigurationService.test.ts @@ -4,8 +4,9 @@ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; import { McpToolServerConfigurationService } from '../../packages/agents-a365-tooling/src/McpToolServerConfigurationService'; import { Utility } from '../../packages/agents-a365-tooling/src/Utility'; +import { ToolingConfiguration, defaultToolingConfigurationProvider } from '../../packages/agents-a365-tooling/src/configuration'; import { TurnContext, Authorization } from '@microsoft/agents-hosting'; -import { AgenticAuthenticationService, Utility as RuntimeUtility } from '@microsoft/agents-a365-runtime'; +import { AgenticAuthenticationService, DefaultConfigurationProvider, Utility as RuntimeUtility } from '@microsoft/agents-a365-runtime'; import fs from 'fs'; describe('McpToolServerConfigurationService', () => { @@ -264,6 +265,71 @@ describe('McpToolServerConfigurationService', () => { expect(servers[1].url).toBe('http://localhost:4000/another-mcp'); expect(servers[1].headers).toBeUndefined(); }); + + it('should return empty array and log error when manifest contains invalid JSON', async () => { + // Arrange + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'readFileSync').mockReturnValue('{ invalid json }'); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + // Act + const servers = await service.listToolServers('test-agent-id', 'mock-auth-token'); + + // Assert + expect(servers).toHaveLength(0); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error reading or parsing ToolingManifest.json') + ); + }); + }); + + describe('isDevScenario detection', () => { + it.each([ + ['Development', true], + ['development', true], + ['DEVELOPMENT', true], + ['DeVeLoPmEnT', true], + ])('should detect development mode when NODE_ENV is "%s"', async (nodeEnv, expected) => { + // Arrange + process.env.NODE_ENV = nodeEnv; + const manifestContent = { mcpServers: [{ mcpServerName: 'testServer', url: 'http://test.com' }] }; + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent)); + + // Act + const servers = await service.listToolServers('test-agent-id', 'mock-auth-token'); + + // Assert - if dev scenario, it reads from manifest and returns the server + if (expected) { + expect(servers).toHaveLength(1); + expect(servers[0].mcpServerName).toBe('testServer'); + } + }); + + it.each([ + ['production'], + ['Production'], + ['PRODUCTION'], + ['staging'], + ['test'], + [''], + ])('should use gateway (not manifest) when NODE_ENV is "%s"', async (nodeEnv) => { + // Arrange + process.env.NODE_ENV = nodeEnv; + + // Act & Assert - In production mode, the service calls the gateway which requires auth token + // The error "Authentication token is required" comes from Utility.ValidateAuthToken + // which is only called in production mode (gateway path) + await expect(service.listToolServers('test-agent-id', '')).rejects.toThrow('Authentication token is required'); + }); + + it('should use gateway (not manifest) when NODE_ENV is undefined', async () => { + // Arrange + delete process.env.NODE_ENV; + + // Act & Assert - In production mode (default), the service calls the gateway which requires auth token + await expect(service.listToolServers('test-agent-id', '')).rejects.toThrow('Authentication token is required'); + }); }); describe('listToolServers legacy signatures (deprecated)', () => { @@ -368,7 +434,7 @@ describe('McpToolServerConfigurationService', () => { // Assert expect(servers).toHaveLength(1); - expect(getAgenticUserTokenSpy).toHaveBeenCalledWith(mockAuthorization, 'graph', mockContext); + expect(getAgenticUserTokenSpy).toHaveBeenCalledWith(mockAuthorization, 'graph', mockContext, ['ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default']); expect(resolveAgentIdentitySpy).toHaveBeenCalledWith(mockContext, mockToken); }); @@ -571,7 +637,7 @@ describe('McpToolServerConfigurationService', () => { // Assert - token should still be auto-generated even in dev mode expect(servers).toHaveLength(1); - expect(getAgenticUserTokenSpy).toHaveBeenCalledWith(mockAuthorization, 'graph', mockContext); + expect(getAgenticUserTokenSpy).toHaveBeenCalledWith(mockAuthorization, 'graph', mockContext, ['ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default']); }); }); @@ -661,4 +727,397 @@ describe('McpToolServerConfigurationService', () => { ); }); }); + + describe('configuration provider injection', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + describe('custom mcpPlatformEndpoint override', () => { + it('should use custom endpoint when configuration override is provided', async () => { + // Arrange + process.env.NODE_ENV = 'production'; + const customEndpoint = 'https://custom.tenant.endpoint.com'; + + const customConfig = new ToolingConfiguration({ + mcpPlatformEndpoint: () => customEndpoint, + useToolingManifest: () => false + }); + const customProvider = new DefaultConfigurationProvider(() => customConfig); + const serviceWithCustomConfig = new McpToolServerConfigurationService(customProvider); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const axios = require('axios'); + const axiosGetSpy = jest.spyOn(axios, 'get').mockResolvedValue({ + data: [{ mcpServerName: 'testServer', url: 'http://test.com' }] + }); + jest.spyOn(Utility, 'ValidateAuthToken').mockImplementation(() => {}); + + const mockToken = createMockJwt(); + + // Act + await serviceWithCustomConfig.listToolServers('my-agent-id', mockToken); + + // Assert - verify the custom endpoint is used in the gateway URL + expect(axiosGetSpy).toHaveBeenCalledWith( + `${customEndpoint}/agents/my-agent-id/mcpServers`, + expect.any(Object) + ); + }); + + it('should use different endpoints for different tenant configurations', async () => { + // Arrange - simulates multi-tenant scenario + process.env.NODE_ENV = 'production'; + + const tenant1Endpoint = 'https://tenant1.example.com'; + const tenant2Endpoint = 'https://tenant2.example.com'; + + const tenant1Config = new ToolingConfiguration({ + mcpPlatformEndpoint: () => tenant1Endpoint, + useToolingManifest: () => false + }); + const tenant1Provider = new DefaultConfigurationProvider(() => tenant1Config); + const service1 = new McpToolServerConfigurationService(tenant1Provider); + + const tenant2Config = new ToolingConfiguration({ + mcpPlatformEndpoint: () => tenant2Endpoint, + useToolingManifest: () => false + }); + const tenant2Provider = new DefaultConfigurationProvider(() => tenant2Config); + const service2 = new McpToolServerConfigurationService(tenant2Provider); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const axios = require('axios'); + const axiosGetSpy = jest.spyOn(axios, 'get').mockResolvedValue({ data: [] }); + jest.spyOn(Utility, 'ValidateAuthToken').mockImplementation(() => {}); + + const mockToken = createMockJwt(); + + // Act + await service1.listToolServers('agent-1', mockToken); + await service2.listToolServers('agent-2', mockToken); + + // Assert - each service uses its own endpoint + expect(axiosGetSpy).toHaveBeenNthCalledWith( + 1, + `${tenant1Endpoint}/agents/agent-1/mcpServers`, + expect.any(Object) + ); + expect(axiosGetSpy).toHaveBeenNthCalledWith( + 2, + `${tenant2Endpoint}/agents/agent-2/mcpServers`, + expect.any(Object) + ); + }); + + it('should normalize endpoint URL by removing trailing slashes', async () => { + // Arrange + process.env.NODE_ENV = 'production'; + const customEndpoint = 'https://custom.endpoint.com///'; + + const customConfig = new ToolingConfiguration({ + mcpPlatformEndpoint: () => customEndpoint, + useToolingManifest: () => false + }); + const customProvider = new DefaultConfigurationProvider(() => customConfig); + const serviceWithCustomConfig = new McpToolServerConfigurationService(customProvider); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const axios = require('axios'); + const axiosGetSpy = jest.spyOn(axios, 'get').mockResolvedValue({ data: [] }); + jest.spyOn(Utility, 'ValidateAuthToken').mockImplementation(() => {}); + + const mockToken = createMockJwt(); + + // Act + await serviceWithCustomConfig.listToolServers('my-agent-id', mockToken); + + // Assert - URL should not have double slashes + expect(axiosGetSpy).toHaveBeenCalledWith( + 'https://custom.endpoint.com/agents/my-agent-id/mcpServers', + expect.any(Object) + ); + }); + }); + + describe('custom useToolingManifest override', () => { + it('should force manifest mode when override returns true even in production', async () => { + // Arrange + process.env.NODE_ENV = 'production'; // Normally would use gateway + + const customConfig = new ToolingConfiguration({ + useToolingManifest: () => true // Force manifest mode + }); + const customProvider = new DefaultConfigurationProvider(() => customConfig); + const serviceWithCustomConfig = new McpToolServerConfigurationService(customProvider); + + const manifestContent = { + mcpServers: [{ mcpServerName: 'manifestServer', url: 'http://manifest.local' }] + }; + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent)); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const axios = require('axios'); + const axiosGetSpy = jest.spyOn(axios, 'get'); + + // Act + const servers = await serviceWithCustomConfig.listToolServers('my-agent-id', 'mock-token'); + + // Assert - should read from manifest, not call gateway + expect(servers).toHaveLength(1); + expect(servers[0].mcpServerName).toBe('manifestServer'); + expect(servers[0].url).toBe('http://manifest.local'); + expect(axiosGetSpy).not.toHaveBeenCalled(); + }); + + it('should force gateway mode when override returns false even in development', async () => { + // Arrange + process.env.NODE_ENV = 'Development'; // Normally would use manifest + + const customConfig = new ToolingConfiguration({ + useToolingManifest: () => false // Force gateway mode + }); + const customProvider = new DefaultConfigurationProvider(() => customConfig); + const serviceWithCustomConfig = new McpToolServerConfigurationService(customProvider); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const axios = require('axios'); + const axiosGetSpy = jest.spyOn(axios, 'get').mockResolvedValue({ + data: [{ mcpServerName: 'gatewayServer', url: 'http://gateway.com' }] + }); + jest.spyOn(Utility, 'ValidateAuthToken').mockImplementation(() => {}); + + const readFileSpy = jest.spyOn(fs, 'readFileSync'); + const mockToken = createMockJwt(); + + // Act + const servers = await serviceWithCustomConfig.listToolServers('my-agent-id', mockToken); + + // Assert - should call gateway, not read manifest + expect(servers).toHaveLength(1); + expect(servers[0].mcpServerName).toBe('gatewayServer'); + expect(axiosGetSpy).toHaveBeenCalled(); + expect(readFileSpy).not.toHaveBeenCalled(); + }); + + it('should allow dynamic manifest/gateway switching based on context', async () => { + // Arrange - simulate a dynamic override that changes behavior per request + let useManifest = true; + + const customConfig = new ToolingConfiguration({ + useToolingManifest: () => useManifest // Dynamic based on external state + }); + const customProvider = new DefaultConfigurationProvider(() => customConfig); + const serviceWithCustomConfig = new McpToolServerConfigurationService(customProvider); + + const manifestContent = { + mcpServers: [{ mcpServerName: 'manifestServer', url: 'http://manifest.local' }] + }; + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent)); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const axios = require('axios'); + const axiosGetSpy = jest.spyOn(axios, 'get').mockResolvedValue({ + data: [{ mcpServerName: 'gatewayServer', url: 'http://gateway.com' }] + }); + jest.spyOn(Utility, 'ValidateAuthToken').mockImplementation(() => {}); + + const mockToken = createMockJwt(); + + // Act 1 - with useManifest = true + const servers1 = await serviceWithCustomConfig.listToolServers('my-agent-id', mockToken); + expect(servers1[0].mcpServerName).toBe('manifestServer'); + expect(axiosGetSpy).not.toHaveBeenCalled(); + + // Change the dynamic value + useManifest = false; + + // Act 2 - with useManifest = false (same service instance) + const servers2 = await serviceWithCustomConfig.listToolServers('my-agent-id', mockToken); + expect(servers2[0].mcpServerName).toBe('gatewayServer'); + expect(axiosGetSpy).toHaveBeenCalled(); + }); + }); + + describe('custom mcpPlatformAuthenticationScope override', () => { + let mockContext: TurnContext; + let mockAuthorization: Authorization; + let getAgenticUserTokenSpy: jest.SpiedFunction; + let resolveAgentIdentitySpy: jest.SpiedFunction; + + beforeEach(() => { + mockContext = { + activity: { + from: { agenticAppBlueprintId: 'blueprint-123' }, + channelId: 'msteams', + recipient: { id: 'recipient-id' }, + conversation: { id: 'conversation-id' }, + isAgenticRequest: jest.fn().mockReturnValue(false), + getAgenticInstanceId: jest.fn().mockReturnValue(undefined) + }, + sendActivity: jest.fn() + } as unknown as TurnContext; + + mockAuthorization = {} as Authorization; + + getAgenticUserTokenSpy = jest.spyOn(AgenticAuthenticationService, 'GetAgenticUserToken'); + resolveAgentIdentitySpy = jest.spyOn(RuntimeUtility, 'ResolveAgentIdentity'); + }); + + afterEach(() => { + getAgenticUserTokenSpy.mockRestore(); + resolveAgentIdentitySpy.mockRestore(); + }); + + it('should use custom authentication scope when auto-generating token', async () => { + // Arrange + const customScope = 'api://custom-app-id/.default'; + + const customConfig = new ToolingConfiguration({ + mcpPlatformAuthenticationScope: () => customScope, + useToolingManifest: () => true // Use manifest to avoid gateway complications + }); + const customProvider = new DefaultConfigurationProvider(() => customConfig); + const serviceWithCustomConfig = new McpToolServerConfigurationService(customProvider); + + const manifestContent = { + mcpServers: [{ mcpServerName: 'testServer', url: 'http://localhost:3000' }] + }; + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent)); + + const mockToken = createMockJwt(); + getAgenticUserTokenSpy.mockResolvedValue(mockToken); + resolveAgentIdentitySpy.mockReturnValue('resolved-agent-id'); + + // Act - call without authToken to trigger auto-generation + await serviceWithCustomConfig.listToolServers(mockContext, mockAuthorization, 'graph'); + + // Assert - GetAgenticUserToken should be called with the custom scope + expect(getAgenticUserTokenSpy).toHaveBeenCalledWith( + mockAuthorization, + 'graph', + mockContext, + [customScope] + ); + }); + + it('should use different scopes for different tenant configurations', async () => { + // Arrange - simulates multi-tenant scenario with different auth requirements + const tenant1Scope = 'api://tenant1-app-id/.default'; + const tenant2Scope = 'api://tenant2-app-id/.default'; + + const tenant1Config = new ToolingConfiguration({ + mcpPlatformAuthenticationScope: () => tenant1Scope, + useToolingManifest: () => true + }); + const tenant1Provider = new DefaultConfigurationProvider(() => tenant1Config); + const service1 = new McpToolServerConfigurationService(tenant1Provider); + + const tenant2Config = new ToolingConfiguration({ + mcpPlatformAuthenticationScope: () => tenant2Scope, + useToolingManifest: () => true + }); + const tenant2Provider = new DefaultConfigurationProvider(() => tenant2Config); + const service2 = new McpToolServerConfigurationService(tenant2Provider); + + const manifestContent = { + mcpServers: [{ mcpServerName: 'testServer', url: 'http://localhost:3000' }] + }; + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent)); + + const mockToken = createMockJwt(); + getAgenticUserTokenSpy.mockResolvedValue(mockToken); + resolveAgentIdentitySpy.mockReturnValue('resolved-agent-id'); + + // Act + await service1.listToolServers(mockContext, mockAuthorization, 'graph'); + await service2.listToolServers(mockContext, mockAuthorization, 'graph'); + + // Assert - each service uses its own scope + expect(getAgenticUserTokenSpy).toHaveBeenNthCalledWith( + 1, + mockAuthorization, + 'graph', + mockContext, + [tenant1Scope] + ); + expect(getAgenticUserTokenSpy).toHaveBeenNthCalledWith( + 2, + mockAuthorization, + 'graph', + mockContext, + [tenant2Scope] + ); + }); + }); + + describe('default configuration provider behavior', () => { + it('should use default configuration when no provider is specified', async () => { + // Arrange + process.env.NODE_ENV = 'Development'; + const defaultService = new McpToolServerConfigurationService(); + const serviceWithExplicitDefault = new McpToolServerConfigurationService(defaultToolingConfigurationProvider); + + const manifestContent = { + mcpServers: [{ mcpServerName: 'testServer', url: 'http://localhost:3000' }] + }; + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(manifestContent)); + + // Act + const servers1 = await defaultService.listToolServers('agent-id', 'mock-token'); + const servers2 = await serviceWithExplicitDefault.listToolServers('agent-id', 'mock-token'); + + // Assert - both should behave identically + expect(servers1).toEqual(servers2); + }); + + it('should respect environment variables when using default configuration', async () => { + // Arrange + const customEndpoint = 'https://env-based-endpoint.com'; + process.env.MCP_PLATFORM_ENDPOINT = customEndpoint; + process.env.NODE_ENV = 'production'; + + // Create a fresh configuration to pick up env var + const freshConfig = new ToolingConfiguration(); + const freshProvider = new DefaultConfigurationProvider(() => freshConfig); + const serviceWithFreshConfig = new McpToolServerConfigurationService(freshProvider); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const axios = require('axios'); + const axiosGetSpy = jest.spyOn(axios, 'get').mockResolvedValue({ data: [] }); + jest.spyOn(Utility, 'ValidateAuthToken').mockImplementation(() => {}); + + const mockToken = createMockJwt(); + + // Act + await serviceWithFreshConfig.listToolServers('my-agent-id', mockToken); + + // Assert - should use the environment-based endpoint + expect(axiosGetSpy).toHaveBeenCalledWith( + `${customEndpoint}/agents/my-agent-id/mcpServers`, + expect.any(Object) + ); + }); + }); + + // Helper to create mock JWT tokens + function createMockJwt(): string { + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString('base64url'); + const signature = 'mock-signature'; + return `${header}.${payload}.${signature}`; + } + }); }); diff --git a/tests/tooling/configuration/ToolingConfiguration.test.ts b/tests/tooling/configuration/ToolingConfiguration.test.ts new file mode 100644 index 00000000..787acbf6 --- /dev/null +++ b/tests/tooling/configuration/ToolingConfiguration.test.ts @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + ToolingConfiguration, + defaultToolingConfigurationProvider +} from '../../../packages/agents-a365-tooling/src'; +import { RuntimeConfiguration, DefaultConfigurationProvider, ClusterCategory } from '../../../packages/agents-a365-runtime/src'; + +describe('ToolingConfiguration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('inheritance from RuntimeConfiguration', () => { + it('should inherit runtime settings', () => { + const config = new ToolingConfiguration({ clusterCategory: () => ClusterCategory.gov }); + expect(config.clusterCategory).toBe(ClusterCategory.gov); + expect(config.isDevelopmentEnvironment).toBe(false); + }); + + it('should be instanceof RuntimeConfiguration', () => { + const config = new ToolingConfiguration(); + expect(config).toBeInstanceOf(RuntimeConfiguration); + }); + + it('should inherit clusterCategory from env var', () => { + process.env.CLUSTER_CATEGORY = 'dev'; + const config = new ToolingConfiguration({}); + expect(config.clusterCategory).toBe('dev'); + expect(config.isDevelopmentEnvironment).toBe(true); + }); + }); + + describe('mcpPlatformEndpoint', () => { + it('should use override function when provided', () => { + const config = new ToolingConfiguration({ + mcpPlatformEndpoint: () => 'https://custom.endpoint' + }); + expect(config.mcpPlatformEndpoint).toBe('https://custom.endpoint'); + }); + + it('should fall back to env var when override not provided', () => { + process.env.MCP_PLATFORM_ENDPOINT = 'https://env.endpoint'; + const config = new ToolingConfiguration({}); + expect(config.mcpPlatformEndpoint).toBe('https://env.endpoint'); + }); + + it('should fall back to default when neither override nor env var', () => { + delete process.env.MCP_PLATFORM_ENDPOINT; + const config = new ToolingConfiguration({}); + expect(config.mcpPlatformEndpoint).toBe('https://agent365.svc.cloud.microsoft'); + }); + + it('should fall back to default when env var is empty string', () => { + process.env.MCP_PLATFORM_ENDPOINT = ''; + const config = new ToolingConfiguration({}); + expect(config.mcpPlatformEndpoint).toBe('https://agent365.svc.cloud.microsoft'); + }); + + it('should fall back to env var when override returns empty string', () => { + process.env.MCP_PLATFORM_ENDPOINT = 'https://env.endpoint'; + const config = new ToolingConfiguration({ + mcpPlatformEndpoint: () => '' + }); + expect(config.mcpPlatformEndpoint).toBe('https://env.endpoint'); + }); + + it('should fall back to default when override returns empty string and no env var', () => { + delete process.env.MCP_PLATFORM_ENDPOINT; + const config = new ToolingConfiguration({ + mcpPlatformEndpoint: () => '' + }); + expect(config.mcpPlatformEndpoint).toBe('https://agent365.svc.cloud.microsoft'); + }); + + it('should remove trailing slashes from env var', () => { + process.env.MCP_PLATFORM_ENDPOINT = 'https://env.endpoint///'; + const config = new ToolingConfiguration({}); + expect(config.mcpPlatformEndpoint).toBe('https://env.endpoint'); + }); + + it('should remove trailing slashes from override', () => { + const config = new ToolingConfiguration({ + mcpPlatformEndpoint: () => 'https://custom.endpoint/' + }); + expect(config.mcpPlatformEndpoint).toBe('https://custom.endpoint'); + }); + + it('should trim whitespace from env var', () => { + process.env.MCP_PLATFORM_ENDPOINT = ' https://env.endpoint '; + const config = new ToolingConfiguration({}); + expect(config.mcpPlatformEndpoint).toBe('https://env.endpoint'); + }); + + it('should call override function on each access', () => { + let callCount = 0; + const config = new ToolingConfiguration({ + mcpPlatformEndpoint: () => { + callCount++; + return 'https://dynamic.endpoint'; + } + }); + config.mcpPlatformEndpoint; + config.mcpPlatformEndpoint; + expect(callCount).toBe(2); + }); + }); + + describe('mcpPlatformAuthenticationScope', () => { + it('should use override function when provided', () => { + const config = new ToolingConfiguration({ + mcpPlatformAuthenticationScope: () => 'custom-scope/.default' + }); + expect(config.mcpPlatformAuthenticationScope).toBe('custom-scope/.default'); + }); + + it('should fall back to env var when override not provided', () => { + process.env.MCP_PLATFORM_AUTHENTICATION_SCOPE = 'env-scope/.default'; + const config = new ToolingConfiguration({}); + expect(config.mcpPlatformAuthenticationScope).toBe('env-scope/.default'); + }); + + it('should fall back to default when neither override nor env var', () => { + delete process.env.MCP_PLATFORM_AUTHENTICATION_SCOPE; + const config = new ToolingConfiguration({}); + expect(config.mcpPlatformAuthenticationScope).toBe('ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default'); + }); + + it('should fall back to default when env var is empty string', () => { + process.env.MCP_PLATFORM_AUTHENTICATION_SCOPE = ''; + const config = new ToolingConfiguration({}); + expect(config.mcpPlatformAuthenticationScope).toBe('ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default'); + }); + + it('should fall back to env var when override returns empty string', () => { + process.env.MCP_PLATFORM_AUTHENTICATION_SCOPE = 'env-scope/.default'; + const config = new ToolingConfiguration({ + mcpPlatformAuthenticationScope: () => '' + }); + expect(config.mcpPlatformAuthenticationScope).toBe('env-scope/.default'); + }); + + it('should fall back to default when override returns empty string and no env var', () => { + delete process.env.MCP_PLATFORM_AUTHENTICATION_SCOPE; + const config = new ToolingConfiguration({ + mcpPlatformAuthenticationScope: () => '' + }); + expect(config.mcpPlatformAuthenticationScope).toBe('ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default'); + }); + }); + + describe('useToolingManifest', () => { + it('should use override function when provided', () => { + const config = new ToolingConfiguration({ + useToolingManifest: () => true + }); + expect(config.useToolingManifest).toBe(true); + }); + + it('should return false override when provided', () => { + process.env.NODE_ENV = 'development'; + const config = new ToolingConfiguration({ + useToolingManifest: () => false + }); + // Override takes precedence over NODE_ENV + expect(config.useToolingManifest).toBe(false); + }); + + it('should return true when NODE_ENV is development (lowercase)', () => { + process.env.NODE_ENV = 'development'; + const config = new ToolingConfiguration({}); + expect(config.useToolingManifest).toBe(true); + }); + + it('should return true when NODE_ENV is Development (mixed case)', () => { + process.env.NODE_ENV = 'Development'; + const config = new ToolingConfiguration({}); + expect(config.useToolingManifest).toBe(true); + }); + + it('should return true when NODE_ENV is DEVELOPMENT (uppercase)', () => { + process.env.NODE_ENV = 'DEVELOPMENT'; + const config = new ToolingConfiguration({}); + expect(config.useToolingManifest).toBe(true); + }); + + it('should return false when NODE_ENV is production', () => { + process.env.NODE_ENV = 'production'; + const config = new ToolingConfiguration({}); + expect(config.useToolingManifest).toBe(false); + }); + + it('should return false when NODE_ENV is not set', () => { + delete process.env.NODE_ENV; + const config = new ToolingConfiguration({}); + expect(config.useToolingManifest).toBe(false); + }); + + it('should return false when NODE_ENV is empty string', () => { + process.env.NODE_ENV = ''; + const config = new ToolingConfiguration({}); + expect(config.useToolingManifest).toBe(false); + }); + + it('should call override function on each access', () => { + let callCount = 0; + const config = new ToolingConfiguration({ + useToolingManifest: () => { + callCount++; + return true; + } + }); + config.useToolingManifest; + config.useToolingManifest; + expect(callCount).toBe(2); + }); + }); + + describe('combined overrides', () => { + it('should allow overriding both runtime and tooling settings', () => { + const config = new ToolingConfiguration({ + clusterCategory: () => ClusterCategory.dev, + mcpPlatformEndpoint: () => 'https://dev.endpoint' + }); + expect(config.clusterCategory).toBe(ClusterCategory.dev); + expect(config.isDevelopmentEnvironment).toBe(true); + expect(config.mcpPlatformEndpoint).toBe('https://dev.endpoint'); + }); + + it('should support dynamic per-tenant configuration', () => { + let currentTenant = 'tenant-a'; + const tenantEndpoints: Record = { + 'tenant-a': 'https://tenant-a.endpoint', + 'tenant-b': 'https://tenant-b.endpoint' + }; + + const config = new ToolingConfiguration({ + mcpPlatformEndpoint: () => tenantEndpoints[currentTenant] + }); + + expect(config.mcpPlatformEndpoint).toBe('https://tenant-a.endpoint'); + currentTenant = 'tenant-b'; + expect(config.mcpPlatformEndpoint).toBe('https://tenant-b.endpoint'); + }); + }); +}); + +describe('defaultToolingConfigurationProvider', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should be an instance of DefaultConfigurationProvider', () => { + expect(defaultToolingConfigurationProvider).toBeInstanceOf(DefaultConfigurationProvider); + }); + + it('should return ToolingConfiguration from getConfiguration', () => { + const config = defaultToolingConfigurationProvider.getConfiguration(); + expect(config).toBeInstanceOf(ToolingConfiguration); + }); + + it('should return the same configuration instance on multiple calls', () => { + const config1 = defaultToolingConfigurationProvider.getConfiguration(); + const config2 = defaultToolingConfigurationProvider.getConfiguration(); + expect(config1).toBe(config2); + }); +});