();
-// Map A2A endpoints
-app.MapA2A(taskManager, "/");
-app.MapHttpA2A(taskManager, "/");
+// Map A2A endpoints with authentication requirement
+app.MapA2A(taskManager, "/").RequireAuthorization("AdminOnly");
+app.MapHttpA2A(taskManager, "/").RequireAuthorization("AdminOnly");
app.MapDefaultEndpoints();
diff --git a/src/ChatApp/ChatApp.csproj b/src/ChatApp/ChatApp.csproj
new file mode 100644
index 0000000..3f029ac
--- /dev/null
+++ b/src/ChatApp/ChatApp.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net10.0
+ enable
+ enable
+ f2fe61b0-2fc1-4560-820c-41bc975cb1d2
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ChatApp/Components/App.razor b/src/ChatApp/Components/App.razor
new file mode 100644
index 0000000..57a625f
--- /dev/null
+++ b/src/ChatApp/Components/App.razor
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false);
+}
\ No newline at end of file
diff --git a/src/ChatApp/Components/Layout/MainLayout.razor b/src/ChatApp/Components/Layout/MainLayout.razor
new file mode 100644
index 0000000..54a6694
--- /dev/null
+++ b/src/ChatApp/Components/Layout/MainLayout.razor
@@ -0,0 +1,7 @@
+@inherits LayoutComponentBase
+
+
+
+ @Body
+
+
\ No newline at end of file
diff --git a/src/ChatApp/Components/Pages/Home.razor b/src/ChatApp/Components/Pages/Home.razor
new file mode 100644
index 0000000..bdf4f35
--- /dev/null
+++ b/src/ChatApp/Components/Pages/Home.razor
@@ -0,0 +1,7 @@
+@page "/"
+
+Coffee Shop Agent
+
+Welcome to Coffee Shop Agent!
+
+This is a Blazor web app for interacting with the Coffee Shop Agent system.
\ No newline at end of file
diff --git a/src/ChatApp/Components/Routes.razor b/src/ChatApp/Components/Routes.razor
new file mode 100644
index 0000000..8eab673
--- /dev/null
+++ b/src/ChatApp/Components/Routes.razor
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ChatApp/Components/_Imports.razor b/src/ChatApp/Components/_Imports.razor
new file mode 100644
index 0000000..6cde179
--- /dev/null
+++ b/src/ChatApp/Components/_Imports.razor
@@ -0,0 +1,12 @@
+@using System.Net.Http
+@using System.Net.Http.Json
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using Microsoft.AspNetCore.Components.Web.Virtualization
+@using Microsoft.Extensions.AI
+@using Microsoft.JSInterop
+@using ChatApp
+@using ChatApp.Components
+@using ChatApp.Components.Layout
\ No newline at end of file
diff --git a/src/ChatApp/Program.cs b/src/ChatApp/Program.cs
new file mode 100644
index 0000000..cd5e6f2
--- /dev/null
+++ b/src/ChatApp/Program.cs
@@ -0,0 +1,134 @@
+using System.Net.Http.Headers;
+using ChatApp.Components;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+using Microsoft.Extensions.Primitives;
+using Microsoft.Identity.Web;
+
+// Ref: https://github.com/damienbod/BlazorServerOidc/tree/main/BlazorWebApp
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddRazorComponents().AddInteractiveServerComponents();
+
+builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
+ .AddMicrosoftIdentityWebApp(options =>
+ {
+ builder.Configuration.Bind("AzureAd", options);
+
+ options.SaveTokens = true;
+ options.Scope.Add($"api://{builder.Configuration["AzureAd:ClientId"]}/CoffeeShop.Counter.ReadWrite");
+
+ options.Events = new OpenIdConnectEvents
+ {
+ OnTokenValidated = CustomTokenValidated,
+ OnAuthenticationFailed = CustomAuthenticationFailed
+ };
+
+ }, options => builder.Configuration.Bind("AzureAd", options));
+
+builder.Services.AddHttpContextAccessor();
+
+builder.Services.AddScoped();
+
+builder.Services.AddHttpClient("CounterClient",
+ client => client.BaseAddress = new Uri("https+http://counter" ??
+ throw new Exception("Missing base address!")))
+ .AddHttpMessageHandler();
+
+builder.Services.AddAuthenticationCore();
+builder.Services.AddAuthorization();
+builder.Services.AddCascadingAuthenticationState();
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (!app.Environment.IsDevelopment())
+{
+ app.UseExceptionHandler("/Error", createScopeForErrors: true);
+ // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
+ app.UseHsts();
+}
+
+app.UseHttpsRedirection();
+app.UseAntiforgery();
+
+app.UseAuthentication();
+app.UseAuthorization();
+
+app.UseStaticFiles();
+app.MapRazorComponents()
+ .AddInteractiveServerRenderMode();
+
+app.MapLoginLogoutEndpoints();
+
+app.Run();
+
+async Task CustomTokenValidated(TokenValidatedContext context)
+{
+ await Task.CompletedTask;
+}
+
+async Task CustomAuthenticationFailed(AuthenticationFailedContext context)
+{
+ // Custom logic upon authentication failure
+ await Task.CompletedTask;
+}
+
+public class TokenHandler(IHttpContextAccessor httpContextAccessor) :
+ DelegatingHandler
+{
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (httpContextAccessor.HttpContext is null)
+ {
+ throw new Exception("HttpContext not available");
+ }
+
+ var accessToken = await httpContextAccessor.HttpContext.GetTokenAsync("access_token");
+
+ if (accessToken is null)
+ {
+ throw new Exception("No access token");
+ }
+
+ request.Headers.Authorization =
+ new AuthenticationHeaderValue("Bearer", accessToken);
+
+ return await base.SendAsync(request, cancellationToken);
+ }
+}
+
+public static class LoginLogoutEndpoints
+{
+ public static WebApplication MapLoginLogoutEndpoints(this WebApplication app)
+ {
+ app.MapGet("/login", async context =>
+ {
+ var returnUrl = context.Request.Query["returnUrl"];
+
+ await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties
+ {
+ RedirectUri = returnUrl == StringValues.Empty ? "/" : returnUrl.ToString()
+ });
+ }).AllowAnonymous();
+
+ app.MapPost("/logout", async context =>
+ {
+ if (context.User.Identity?.IsAuthenticated ?? false)
+ {
+ await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
+ await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
+ }
+ else
+ {
+ context.Response.Redirect("/");
+ }
+ });
+
+ return app;
+ }
+
+}
\ No newline at end of file
diff --git a/src/ChatApp/appsettings.Development.json b/src/ChatApp/appsettings.Development.json
new file mode 100644
index 0000000..b6f634e
--- /dev/null
+++ b/src/ChatApp/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "DetailedErrors": true,
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ChatApp/appsettings.json b/src/ChatApp/appsettings.json
new file mode 100644
index 0000000..ec04bc1
--- /dev/null
+++ b/src/ChatApp/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
\ No newline at end of file
diff --git a/src/CounterService/Agents/CounterAgent.cs b/src/CounterService/Agents/CounterAgent.cs
index 6a0dd48..c4b2405 100644
--- a/src/CounterService/Agents/CounterAgent.cs
+++ b/src/CounterService/Agents/CounterAgent.cs
@@ -1,259 +1,196 @@
using System.Diagnostics;
-using System.Text.Json;
-using System.Text.Json.Schema;
-using System.Text.Json.Serialization;
using A2A;
-using CounterService.Models;
-using Microsoft.SemanticKernel;
-using Microsoft.SemanticKernel.Agents;
-using Microsoft.SemanticKernel.ChatCompletion;
-using ModelContextProtocol.Client;
+using ServiceDefaults;
+using ServiceDefaults.Agents;
+using ServiceDefaults.Configuration;
+using ServiceDefaults.Services;
+using ServiceDefaults.Models;
+using Microsoft.Extensions.Logging;
namespace CounterService.Agents;
-public class CounterAgent(
- Kernel kernel,
- IConfiguration configuration,
- IHttpClientFactory httpClientFactory,
- IHttpContextAccessor httpContextAccessor,
- ILogger logger)
+///
+/// Refactored CounterAgent following SOLID principles and using dependency injection.
+/// This implementation splits the previous monolithic CounterAgent into focused,
+/// single-responsibility services while maintaining the same functionality.
+///
+/// Improvements:
+/// - SRP: Uses specialized services for each concern (validation, parsing, messaging, etc.)
+/// - OCP: Extensible through dependency injection and configuration
+/// - DIP: Depends on abstractions rather than concrete implementations
+/// - Security: Implements input validation and secure error handling
+///
+/// Reference: Clean Code by Robert Martin - Chapter 14: Successive Refinement
+/// Reference: SOLID Principles in C# - https://docs.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/architectural-principles#solid
+///
+public class CounterAgent : BaseAgent
{
- private ITaskManager? _taskManager;
- public static readonly ActivitySource ActivitySource = new($"A2A.{nameof(CounterAgent)}", "1.0.0");
-
- public ILogger Logger { get; } = logger;
- public Kernel Kernel { get; } = kernel;
- public IConfiguration Configuration { get; } = configuration;
- public IHttpClientFactory HttpClientFactory { get; } = httpClientFactory;
- public IHttpContextAccessor HttpContextAccessor { get; } = httpContextAccessor;
- public Dictionary DownStreamAgentEndpoints { get; set; } = new Dictionary {
- { configuration["BaristaService:Key"] ?? "BARISTA", configuration["BaristaService:Url"] ?? "http://localhost:5002" },
- { configuration["KitchenService:Key"] ?? "KITCHEN", configuration["KitchenService:Url"] ?? "http://localhost:5003" }
- };
- public Dictionary A2AClients { get; set; } = [];
-
- public void Attach(ITaskManager taskManager)
+ private readonly IAgentConfigurationService _configurationService;
+ private readonly IA2AClientManager _clientManager;
+ private readonly IInputValidationService _validationService;
+ private readonly IOrderParsingService _orderParsingService;
+ private readonly IA2AMessageService _messageService;
+
+ public CounterAgent(
+ IAgentConfigurationService configurationService,
+ IA2AClientManager clientManager,
+ IInputValidationService validationService,
+ IOrderParsingService orderParsingService,
+ IA2AMessageService messageService,
+ IHttpContextAccessor httpContextAccessor,
+ ILogger logger)
+ : base(logger, AgentConstants.ActivitySources.Counter, httpContextAccessor)
{
- _taskManager = taskManager;
- _taskManager.OnTaskCreated = OnTaskCreatedAsync;
- _taskManager.OnTaskUpdated = OnTaskUpdatedAsync;
- _taskManager.OnAgentCardQuery = GetAgentCardAsync;
+ _configurationService = configurationService ?? throw new ArgumentNullException(nameof(configurationService));
+ _clientManager = clientManager ?? throw new ArgumentNullException(nameof(clientManager));
+ _validationService = validationService ?? throw new ArgumentNullException(nameof(validationService));
+ _orderParsingService = orderParsingService ?? throw new ArgumentNullException(nameof(orderParsingService));
+ _messageService = messageService ?? throw new ArgumentNullException(nameof(messageService));
}
- private async Task OnTaskCreatedAsync(AgentTask task, CancellationToken cancellationToken)
+ ///
+ /// Override to initialize A2A clients when task is created with authentication
+ ///
+ protected override async Task OnTaskCreatedAsync(AgentTask task, CancellationToken cancellationToken)
{
using var activity = ActivitySource.StartActivity("OnTaskCreated", ActivityKind.Server);
activity?.SetTag("task.id", task.Id);
- foreach (var (key, endpoint) in DownStreamAgentEndpoints)
+ // Get authentication information first
+ var authResult = await ValidateAuthenticationAsync(task, cancellationToken);
+ if (!authResult.IsAuthenticated)
{
- activity?.SetTag($"downstream.{key.ToLower()}.endpoint", endpoint);
- Logger.LogDebug("Configured downstream agent endpoint: {Endpoint}", endpoint);
-
- var httpClient = HttpClientFactory.CreateClient();
-
- A2ACardResolver cardResolver = new(new Uri(endpoint));
- AgentCard agentCard = await cardResolver.GetAgentCardAsync();
- activity?.SetTag($"downstream.{key.ToLower()}.agentCard.url", agentCard.Url);
- Logger.LogDebug("Resolved Agent card: {Endpoint}", $"{agentCard.Url}");
-
- var client = new A2AClient(new Uri(agentCard.Url), httpClient);
- Logger.LogDebug("Created A2A client for endpoint: {Endpoint}", $"{agentCard.Url}");
-
- if(!A2AClients.ContainsKey(key))
- A2AClients.Add(key, client);
+ return; // Error already handled in ValidateAuthenticationAsync
}
+ // Initialize A2A clients for downstream services with authentication
+ await _clientManager.InitializeClientsAsync(authResult.JwtToken, cancellationToken);
+
Logger.LogInformation("Task created with ID: {TaskId}", task.Id);
- await ProcessTaskAsync(task, cancellationToken);
- }
-
- private async Task OnTaskUpdatedAsync(AgentTask task, CancellationToken cancellationToken)
- {
- using var activity = ActivitySource.StartActivity("OnTaskUpdated", ActivityKind.Server);
- activity?.SetTag("task.id", task.Id);
-
- Logger.LogInformation("Task updated with ID: {TaskId}", task.Id);
- await ProcessTaskAsync(task, cancellationToken);
+ await ProcessTaskCoreAsync(task, cancellationToken);
}
- private async Task ProcessTaskAsync(AgentTask task, CancellationToken cancellationToken)
+ ///
+ /// Core task processing implementation - much more focused and readable
+ /// Now includes JWT token handling for authenticated service calls
+ ///
+ protected override async Task ProcessTaskCoreAsync(AgentTask task, CancellationToken cancellationToken)
{
- using var activity = ActivitySource.StartActivity("ProcessTask", ActivityKind.Server);
- activity?.SetTag("task.id", task.Id);
-
- if (_taskManager == null)
+ // Get authentication info again for this call
+ var authResult = await ValidateAuthenticationAsync(task, cancellationToken);
+ if (!authResult.IsAuthenticated)
{
- throw new InvalidOperationException("TaskManager is not attached.");
+ return; // Error already handled
}
- if (cancellationToken.IsCancellationRequested)
+ // Step 1: Validate input
+ var validationResult = _validationService.ValidateTask(task);
+ if (!validationResult.IsValid)
{
- Logger.LogWarning("Task processing cancelled for ID: {TaskId}", task.Id);
+ await _taskManager!.UpdateStatusAsync(
+ task.Id,
+ TaskState.Failed,
+ new AgentMessage { Parts = [new TextPart { Text = validationResult.ErrorMessage! }] },
+ final: true,
+ cancellationToken: cancellationToken);
return;
}
- try
- {
- // Extract the message from task history
- var lastMessage = task.History?.LastOrDefault();
- if (lastMessage?.Parts == null)
- {
- await _taskManager.UpdateStatusAsync(
- task.Id,
- TaskState.Failed,
- new Message
- {
- Parts = [new TextPart { Text = "No message content found in task" }]
- },
- final: true,
- cancellationToken: cancellationToken);
- return;
- }
+ var messageText = validationResult.TextContent!;
- var messageText = lastMessage.Parts.OfType().FirstOrDefault()?.Text;
- if (string.IsNullOrEmpty(messageText))
+ // Step 2: Update task status to Working
+ await _taskManager!.UpdateStatusAsync(
+ task.Id,
+ TaskState.Working,
+ new AgentMessage
{
- await _taskManager.UpdateStatusAsync(
- task.Id,
- TaskState.Failed,
- new Message
- {
- Parts = [new TextPart { Text = "No text content found in message" }]
- },
- final: true,
- cancellationToken: cancellationToken);
- return;
- }
+ Parts = [new TextPart { Text = $"Processing order via A2A protocol: {messageText}" }]
+ },
+ cancellationToken: cancellationToken);
- // todo: process authn and authz info
- // ...
-
- // Update task status to Working
- await _taskManager.UpdateStatusAsync(
- task.Id,
- TaskState.Working,
- new Message
- {
- Parts = [new TextPart { Text = $"Processing ping message via A2A protocol: {messageText}" }]
- },
- cancellationToken: cancellationToken);
+ // Step 3: Parse the order from the message with authentication
+ Logger.LogInformation("Parsing customer order for task {TaskId}", task.Id);
+ var order = await _orderParsingService.ParseOrderAsync(messageText, authResult.JwtToken, isStub: false, cancellationToken);
- // Send message via A2A protocol to Pong Service
- Logger.LogInformation("Sending A2A message to Pong Service for user: {UserEmail}", "todo@todo.com");
+ // Step 4: Send A2A messages to appropriate services
+ Logger.LogInformation("Sending A2A messages for {BaristaItems} barista items and {KitchenItems} kitchen items",
+ order.BaristaItems.Count, order.KitchenItems.Count);
- var a2aClients = await ParseInputMessage(Kernel, messageText, isStub: true, cancellationToken: cancellationToken);
- activity?.SetTag("a2a.clients.count", a2aClients.Count);
+ var responses = await _messageService.SendOrderMessagesAsync(messageText, order, cancellationToken);
- Logger.LogInformation("Sending A2A message with authentication in HTTP headers");
+ // Step 5: Process responses and return artifacts
+ await ProcessA2AResponsesAsync(task, responses, cancellationToken);
- foreach (var (a2aClient, items) in a2aClients)
+ // Step 6: Complete the task
+ await _taskManager.UpdateStatusAsync(
+ task.Id,
+ TaskState.Completed,
+ new AgentMessage
{
- // Create A2A message with minimal metadata (authentication is in HTTP headers now)
- var a2aMessage = new Message
- {
- Role = MessageRole.User,
- MessageId = Guid.NewGuid().ToString(),
- ContextId = Guid.NewGuid().ToString(),
- Parts = [new TextPart { Text = messageText }],
- Metadata = new Dictionary
- {
- ["items"] = JsonSerializer.SerializeToElement(items),
- ["timestamp"] = JsonSerializer.SerializeToElement(DateTime.UtcNow.ToString("O"))
- }
- };
-
- // Create MessageSendParams for A2A protocol
- var messageSendParams = new MessageSendParams
- {
- Message = a2aMessage,
- Configuration = new MessageSendConfiguration
- {
- AcceptedOutputModes = ["text"],
- Blocking = true
- }
- };
-
- // Send message via A2A protocol with authenticated HTTP client
- var a2aResponse = await a2aClient.SendMessageAsync(messageSendParams);
- activity?.SetTag("a2a.response.type", a2aResponse?.GetType().Name ?? "null");
- var response = MapResponseMessage(a2aResponse);
-
- // Extract the response content in a readable format
- string responseText;
- if (response.Success && response.Data != null)
- {
- // Try to extract the actual response from the task
- if (response.Data.GetType().GetProperty("Response")?.GetValue(response.Data) is string taskResponse)
- {
- responseText = $"Success! Pong Service responded: {taskResponse}";
- }
- else
- {
- responseText = $"A2A task completed successfully. Task ID: {response.Data}";
- }
- }
- else
- {
- responseText = $"A2A communication failed: {response.Message ?? "Unknown error"}";
- }
+ Parts = [new TextPart { Text = "Order processed successfully via A2A protocol" }]
+ },
+ final: true,
+ cancellationToken: cancellationToken);
+ }
- // Return a clean, readable response
- await _taskManager.ReturnArtifactAsync(
- task.Id,
- new Artifact
- {
- Parts = [new TextPart { Text = responseText }]
- },
- cancellationToken);
- }
+ ///
+ /// Processes A2A responses and returns clean, readable responses to the user
+ ///
+ private async Task ProcessA2AResponsesAsync(AgentTask task, List responses, CancellationToken cancellationToken)
+ {
+ foreach (var response in responses)
+ {
+ var responseText = ExtractReadableResponse(response);
- // Complete the task
- await _taskManager.UpdateStatusAsync(
+ await _taskManager!.ReturnArtifactAsync(
task.Id,
- TaskState.Completed,
- new Message
+ new Artifact
{
- Parts = [new TextPart { Text = "Ping message sent successfully via A2A protocol" }]
+ Parts = [new TextPart { Text = responseText }]
},
- final: true,
- cancellationToken: cancellationToken);
+ cancellationToken);
+ }
+ }
- Logger.LogInformation("Task {TaskId} completed successfully", task.Id);
+ ///
+ /// Extracts readable response text from A2A service response
+ ///
+ private static string ExtractReadableResponse(A2AServiceResponse response)
+ {
+ if (response.Success && response.Data != null)
+ {
+ // Try to extract the actual response from the task
+ if (response.Data.GetType().GetProperty("Response")?.GetValue(response.Data) is string taskResponse)
+ {
+ return $"Success! Service responded: {taskResponse}";
+ }
+ else
+ {
+ return $"A2A task completed successfully. Task ID: {response.Data}";
+ }
}
- catch (Exception ex)
+ else
{
- Logger.LogError(ex, "Error processing task {TaskId}", task.Id);
-
- await _taskManager.UpdateStatusAsync(
- task.Id,
- TaskState.Failed,
- new Message
- {
- Parts = [new TextPart { Text = $"Error processing ping message: {ex.Message}" }]
- },
- final: true,
- cancellationToken: cancellationToken);
+ return $"A2A communication failed: {response.Message ?? "Unknown error"}";
}
}
- private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken)
+ ///
+ /// Provides agent card with improved description
+ ///
+ public override Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled(cancellationToken);
}
- var capabilities = new AgentCapabilities
- {
- Streaming = true,
- PushNotifications = false,
- };
+ var capabilities = GetDefaultCapabilities();
return Task.FromResult(new AgentCard
{
Name = "Counter Service Agent",
- Description = "A2A client agent that sends messages through the A2A protocol to the Barista and Kitchen services. " +
+ Description = "A2A client agent that processes customer orders and coordinates with Barista and Kitchen services. " +
"AUTHENTICATION REQUIRED: This agent requires Microsoft Entra ID JWT Bearer token authentication " +
"with 'access_as_user' scope. All requests must include valid JWT tokens in the Authorization header.",
Url = agentUrl,
@@ -264,265 +201,25 @@ private Task GetAgentCardAsync(string agentUrl, CancellationToken can
Skills = [
new AgentSkill
{
- Name = "send_order",
- Description = "Send messages via A2A protocol to Pong Service with MCP integration. " +
+ Name = "process_order",
+ Description = "Process customer orders and coordinate with downstream services via A2A protocol. " +
"Requires JWT authentication with valid user identity and 'access_as_user' scope."
}
],
});
}
- private A2AServiceResponse MapResponseMessage(A2AResponse response)
- {
- switch (response)
- {
- case AgentTask task:
- {
- Logger.LogInformation("A2A task created successfully with ID: {TaskId}, Status: {TaskStatus}",
- task.Id, task.Status.State.ToString());
-
- return new A2AServiceResponse
- {
- Success = true,
- Message = "A2A task created successfully",
- Data = new
- {
- TaskId = task.Id,
- Status = task.Status.State.ToString(),
- Response = task.Artifacts?.FirstOrDefault()?.Parts?.OfType()?.FirstOrDefault()?.Text ?? "Task created"
- }
- };
- }
- case Message messageResponse:
- {
- Logger.LogInformation("Received A2A message response");
-
- var responseText = messageResponse.Parts?.OfType()?.FirstOrDefault()?.Text ?? "No response content";
-
- return new A2AServiceResponse
- {
- Success = true,
- Message = "A2A message sent successfully",
- Data = new
- {
- Response = responseText,
- MessageId = messageResponse.MessageId
- }
- };
- }
-
- default:
- {
- Logger.LogWarning("Unexpected A2A response type: {ResponseType}", response?.GetType().Name ?? "null");
-
- return new A2AServiceResponse
- {
- Success = false,
- Message = "Unexpected response type from A2A protocol",
- Error = $"Unknown response format: {response?.GetType().Name ?? "null"}"
- };
- }
- }
- }
-
- private async Task>> ParseInputMessage(Kernel kernel, string messageText, bool isStub = false, CancellationToken cancellationToken = default)
+ ///
+ /// Override to provide more specific error messages for counter agent
+ ///
+ protected override string GetSanitizedErrorMessage(Exception ex)
{
- var messageClassified = !isStub ? string.Empty :
- """
- {
- "baristaItems": [
- {
- "name": "black coffee",
- "itemType": "COFFEE_BLACK",
- "price": 3
- },
- {
- "name": "cappuccino",
- "itemType": "CAPPUCCINO",
- "price": 3.5
- }
- ],
- "kitchenItems": [
- {
- "name": "cake pop",
- "itemType": "CAKEPOP",
- "price": 5
- }
- ]
- }
- """;
-
- IMcpClient mcpClient = null;
- if (!isStub)
+ return ex switch
{
- // mcp
- var serverUrl = Configuration["McpServer:Url"] ?? "http://localhost:5001";
- var clientName = Configuration["McpServer:ClientName"] ?? "product-catalog-service";
-
- var transport = new SseClientTransport(new()
- {
- Endpoint = new Uri($"{serverUrl}/mcp"),
- Name = clientName
- }, HttpClientFactory.CreateClient());
-
- // Create MCP client using the official factory
- mcpClient = await McpClientFactory.CreateAsync(transport, cancellationToken: cancellationToken);
-
- var tools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken);
-
- var options = JsonSerializerOptions.Default;
- var exporterOptions = new JsonSchemaExporterOptions()
- {
- TreatNullObliviousAsNonNullable = true,
- };
- var schema = options.GetJsonSchemaAsNode(typeof(OrderDto), exporterOptions);
- var promptChunked = $$"""
- Parse a customer's message into a order object in valid JSON.
- Use your tool to extract the name, price, and item type of the customer's message.
- Use your tool to query and get the valid price of the item.
- Use the provided JSON schema for your reply (no markdown for formatting the JSON object needed):
- {{schema}}
-
- EXAMPLE 1:
- Customer's message: I want a black coffee and cappuccino.
- JSON Response:
- ```
- {
- "baristaItems": [
- {
- "name": "black coffee",
- "itemType": "BLACK_COFFEE",
- "price": 3
- },
- {
- "name": "cappuccino",
- "itemType": "CAPPUCCINO",
- "price": 3.5
- }
- ],
- "kitchenItems": []
- }
-
- EXAMPLE 2:
- Customer's message: I want a black coffee, cappuccino and a cakepop.
- JSON Response:
- {
- "baristaItems": [
- {
- "name": "black coffee",
- "itemType": "BLACK_COFFEE",
- "price": 3
- },
- {
- "name": "cappuccino",
- "itemType": "CAPPUCCINO",
- "price": 3.5
- }
- ],
- "kitchenItems": [
- {
- "name": "cakepop",
- "itemType": "CAKEPOP",
- "price": 5
- }
- ]
- }
-
- EXAMPLE 3:
- Customer's message: I want a croissant chocolate.
- JSON Response:
- {
- "baristaItems": [],
- "kitchenItems": [
- {
- "name": "croissant chocolate",
- "itemType": "CROISSANT_CHOCOLATE",
- "price": 5.5
- }
- ]
- }
-
- EXAMPLE 4:
- If you don't know how to parse the order object, respond with:
- {
- "baristaItems": [],
- "kitchenItems": []
- }
- """;
-
- if (!kernel.Plugins.Contains("Tools"))
- {
- kernel.Plugins.AddFromFunctions("Tools", tools.Select(aiFunction => aiFunction.AsKernelFunction()));
- }
-
- var summaryAgent =
- new ChatCompletionAgent()
- {
- Name = "ClassificationAgent",
- Arguments = new KernelArguments(new PromptExecutionSettings()
- { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true }) }),
- Instructions = promptChunked,
- Kernel = kernel
- };
-
- var message = new ChatMessageContent(AuthorRole.User, messageText);
-
-
- await foreach (var msg in summaryAgent.InvokeAsync(message, cancellationToken: cancellationToken))
- {
- messageClassified += msg.Message?.Content;
- }
- }
-
- var orders = JsonSerializer.Deserialize(messageClassified, new JsonSerializerOptions
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- WriteIndented = true
- });
-
- Dictionary> selectedClients = [];
-
- if (orders?.BaristaItems.Count > 0)
- {
- selectedClients.Add(A2AClients["BARISTA"], orders?.BaristaItems);
- }
-
- if (orders?.KitchenItems.Count > 0)
- {
- selectedClients.Add(A2AClients["KITCHEN"], orders?.KitchenItems);
- }
-
- if (mcpClient != null)
- GC.SuppressFinalize(mcpClient);
-
- return selectedClients;
- }
-
- public enum ItemType
- {
- // Beverages
- CAPPUCCINO,
- COFFEE_BLACK,
- COFFEE_WITH_ROOM,
- ESPRESSO,
- ESPRESSO_DOUBLE,
- LATTE,
- // Food
- CAKEPOP,
- CROISSANT,
- MUFFIN,
- CROISSANT_CHOCOLATE,
- // Others
- CHICKEN_MEATBALLS,
- }
- public class ItemTypeDto
- {
- [JsonConverter(typeof(JsonStringEnumConverter))]
- public ItemType ItemType { get; set; }
-
- public string Name { get; set; } = string.Empty;
- public float Price { get; set; }
+ InvalidOperationException => "Service configuration error. Please contact support.",
+ HttpRequestException => "Unable to communicate with downstream services. Please try again later.",
+ TaskCanceledException => "Request timed out. Please try again.",
+ _ => base.GetSanitizedErrorMessage(ex)
+ };
}
-
- public record OrderDto(List BaristaItems, List KitchenItems);
-}
+}
\ No newline at end of file
diff --git a/src/CounterService/CounterService.csproj b/src/CounterService/CounterService.csproj
index de4e567..2d3b2a2 100644
--- a/src/CounterService/CounterService.csproj
+++ b/src/CounterService/CounterService.csproj
@@ -5,6 +5,7 @@
enable
enable
$(NoWarn);VSTHRD111;CA2007;CA1054;SKEXP0001;SKEXP0010;SKEXP0110
+ 984978e2-2a71-4c44-8b98-6cbbcfdf71d0
diff --git a/src/CounterService/Models/Models.cs b/src/CounterService/Models/Models.cs
index fb62cbd..928aea8 100644
--- a/src/CounterService/Models/Models.cs
+++ b/src/CounterService/Models/Models.cs
@@ -1,29 +1,10 @@
-namespace CounterService.Models;
+// This file now uses models from ServiceDefaults.Models
+// The models have been moved to ServiceDefaults for better reusability and to follow DRY principles
-public class A2AServiceResponse
-{
- public bool Success { get; set; }
- public string Message { get; set; } = string.Empty;
- public object? Data { get; set; }
- public string? Error { get; set; }
- public A2AResponseData A2AResponse { get; set; } = new();
- public McpResponseData McpResponse { get; set; } = new();
-}
-
-public class A2AResponseData
-{
- public bool Success { get; set; }
- public string Message { get; set; } = string.Empty;
- public string? Protocol { get; set; }
- public DateTime Timestamp { get; set; }
-}
+using ServiceDefaults.Models;
-public class McpResponseData
+namespace CounterService.Models
{
- public bool Success { get; set; }
- public bool ToolExecuted { get; set; }
- public bool AdminAccess { get; set; }
- public string? ErrorMessage { get; set; }
- public string? ResponseContent { get; set; }
- public DateTime Timestamp { get; set; }
+ // Re-export the models from ServiceDefaults for backward compatibility
+ // This ensures existing code doesn't break while centralizing the models
}
diff --git a/src/CounterService/Program.cs b/src/CounterService/Program.cs
index 618855c..5670cb8 100644
--- a/src/CounterService/Program.cs
+++ b/src/CounterService/Program.cs
@@ -1,10 +1,40 @@
+using System.IdentityModel.Tokens.Jwt;
using A2A;
using A2A.AspNetCore;
using CounterService.Agents;
+using ServiceDefaults.Extensions;
using Microsoft.SemanticKernel;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Identity.Web;
+using Microsoft.IdentityModel.Logging;
var builder = WebApplication.CreateBuilder(args);
+// Configure Azure AD authentication
+builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddMicrosoftIdentityWebApi(options =>
+ {
+ builder.Configuration.Bind("AzureAd", options);
+ options.TokenValidationParameters.ValidateIssuer = true;
+ options.TokenValidationParameters.ValidateAudience = true;
+ options.TokenValidationParameters.ValidateLifetime = true;
+ options.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(5);
+ options.TokenValidationParameters.NameClaimType = "name";
+ options.TokenValidationParameters.RoleClaimType = "role";
+ }, options =>
+ {
+ builder.Configuration.Bind("AzureAd", options);
+ });
+
+builder.Services.AddAuthorization(options =>
+{
+ options.AddPolicy("AdminOnly", policy =>
+ {
+ policy.RequireAuthenticatedUser();
+ policy.RequireClaim("http://schemas.microsoft.com/identity/claims/scope", "admin");
+ });
+});
+
AppContext.SetSwitch("Microsoft.SemanticKernel.Experimental.GenAI.EnableOTelDiagnosticsSensitive", true);
var chatModelId = builder.Configuration.GetConnectionString("chatModelId");
@@ -25,16 +55,32 @@
throw new ArgumentNullException(nameof(apiKey), "The apiKey connection string cannot be null or empty.");
}
-// Register TaskManager as singleton
+// Add agent services following SOLID principles
+builder.Services.AddCounterAgentServices();
+
+// Register TaskManager as singleton with dependency injection
builder.Services.AddSingleton(provider =>
{
var taskManager = new TaskManager();
- var config = provider.GetRequiredService();
- var clientFactory = provider.GetRequiredService();
var logger = provider.GetRequiredService>();
+
+ // Use dependency injection to create the CounterAgent with all required services
+ var configService = provider.GetRequiredService();
+ var clientManager = provider.GetRequiredService();
+ var validationService = provider.GetRequiredService();
+ var orderParsingService = provider.GetRequiredService();
+ var messageService = provider.GetRequiredService();
var httpContextAccessor = provider.GetRequiredService();
- var kernel = provider.GetRequiredService();
- var agent = new CounterAgent(kernel, config, clientFactory, httpContextAccessor, logger);
+
+ var agent = new CounterAgent(
+ configService,
+ clientManager,
+ validationService,
+ orderParsingService,
+ messageService,
+ httpContextAccessor,
+ logger);
+
agent.Attach(taskManager);
return taskManager;
});
@@ -67,12 +113,21 @@
var app = builder.Build();
+JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
+if (app.Environment.IsDevelopment())
+{
+ IdentityModelEventSource.ShowPII = true;
+}
+
+app.UseAuthentication();
+app.UseAuthorization();
+
// Get the configured TaskManager for A2A endpoints
var taskManager = app.Services.GetRequiredService();
-// Map A2A endpoints
-app.MapA2A(taskManager, "/submit-order");
-app.MapHttpA2A(taskManager, "/submit-order");
+// Map A2A endpoints with authentication requirement
+app.MapA2A(taskManager, "/submit-order").RequireAuthorization("AdminOnly");
+app.MapHttpA2A(taskManager, "/submit-order").RequireAuthorization("AdminOnly");
app.MapDefaultEndpoints();
diff --git a/src/KitchenService/Agents/BaristaAgent.cs b/src/KitchenService/Agents/BaristaAgent.cs
deleted file mode 100644
index bcc9011..0000000
--- a/src/KitchenService/Agents/BaristaAgent.cs
+++ /dev/null
@@ -1,118 +0,0 @@
-using System.Diagnostics;
-using A2A;
-
-namespace KitchenService.Agents;
-
-public class KitchenAgent(IHttpContextAccessor httpContextAccessor, ILogger logger)
-{
- private ITaskManager? _taskManager;
- public IHttpContextAccessor HttpContextAccessor { get; } = httpContextAccessor;
- public ILogger Logger { get; } = logger;
-
- public static readonly ActivitySource ActivitySource = new($"A2A.{nameof(KitchenAgent)}", "1.0.0");
-
- public void Attach(ITaskManager taskManager)
- {
- _taskManager = taskManager;
- _taskManager.OnTaskCreated = OnTaskCreatedAsync;
- _taskManager.OnTaskUpdated = OnTaskUpdatedAsync;
- _taskManager.OnAgentCardQuery = GetAgentCardAsync;
- }
-
- private async Task OnTaskCreatedAsync(AgentTask task, CancellationToken cancellationToken)
- {
- using var activity = ActivitySource.StartActivity("OnTaskCreated", ActivityKind.Server);
- activity?.SetTag("task.id", task.Id);
-
- Logger.LogInformation("Task created with ID: {TaskId}", task.Id);
- await ProcessTaskAsync(task, cancellationToken);
- }
-
- private async Task OnTaskUpdatedAsync(AgentTask task, CancellationToken cancellationToken)
- {
- using var activity = ActivitySource.StartActivity("OnTaskUpdated", ActivityKind.Server);
- activity?.SetTag("task.id", task.Id);
-
- Logger.LogInformation("Task updated with ID: {TaskId}", task.Id);
- await ProcessTaskAsync(task, cancellationToken);
- }
-
- private async Task ProcessTaskAsync(AgentTask task, CancellationToken cancellationToken)
- {
- using var activity = ActivitySource.StartActivity("OnTaskUpdated", ActivityKind.Server);
- activity?.SetTag("task.id", task.Id);
-
- if (_taskManager == null)
- {
- throw new InvalidOperationException("TaskManager is not attached.");
- }
-
- try
- {
- // Complete the task
- await _taskManager.UpdateStatusAsync(
- task.Id,
- TaskState.Completed,
- new Message
- {
- Parts = [new TextPart { Text = "Message processed successfully" }]
- },
- final: true,
- cancellationToken: cancellationToken);
-
- Logger.LogInformation("Task {TaskId} completed successfully", task.Id);
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Error processing task {TaskId}", task.Id);
-
- await _taskManager.UpdateStatusAsync(
- task.Id,
- TaskState.Failed,
- new Message
- {
- Parts = [new TextPart { Text = $"Error processing ping message: {ex.Message}" }]
- },
- final: true,
- cancellationToken: cancellationToken);
- }
- }
-
- private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken)
- {
- if (cancellationToken.IsCancellationRequested)
- {
- return Task.FromCanceled(cancellationToken);
- }
-
- var capabilities = new AgentCapabilities
- {
- Streaming = true,
- PushNotifications = false,
- };
-
- // Note: Authentication is implemented at the HTTP transport level using Microsoft Entra ID
- // JWT Bearer tokens are required for all endpoints and are validated by the middleware
- // The authentication scheme used is "Bearer" with JWT tokens containing required scopes
- return Task.FromResult(new AgentCard
- {
- Name = "Kitchen Service Agent",
- Description = "A2A server agent that processes messages and integrates with MCP server for admin users. " +
- "AUTHENTICATION REQUIRED: This agent requires Microsoft Entra ID JWT Bearer token authentication " +
- "with 'access_as_user' scope. All requests must include valid JWT tokens in the Authorization header.",
- Url = agentUrl,
- Version = "1.0.0",
- DefaultInputModes = ["text"],
- DefaultOutputModes = ["text"],
- Capabilities = capabilities,
- Skills = [
- new AgentSkill
- {
- Name = "process_order",
- Description = "Process messages and communicate with MCP server for admin users. " +
- "Requires JWT authentication with admin role and 'access_as_user' scope."
- }
- ],
- });
- }
-}
diff --git a/src/KitchenService/Agents/KitchenAgent.cs b/src/KitchenService/Agents/KitchenAgent.cs
new file mode 100644
index 0000000..01f22ac
--- /dev/null
+++ b/src/KitchenService/Agents/KitchenAgent.cs
@@ -0,0 +1,23 @@
+using ServiceDefaults;
+using ServiceDefaults.Agents;
+using Microsoft.Extensions.Logging;
+
+namespace KitchenService.Agents;
+
+///
+/// Kitchen agent implementation using the common SimpleAgent base class.
+/// This eliminates code duplication and follows DRY principles.
+/// Reference: Clean Code by Robert Martin - Chapter 17: Smells and Heuristics (G5: Duplication)
+///
+public class KitchenAgent : SimpleAgent
+{
+ public KitchenAgent(IHttpContextAccessor httpContextAccessor, ILogger logger)
+ : base(
+ logger,
+ AgentConstants.ActivitySources.Kitchen,
+ httpContextAccessor,
+ "Kitchen Service Agent",
+ "A2A server agent that processes messages and integrates with MCP server for admin users.")
+ {
+ }
+}
diff --git a/src/KitchenService/KitchenService.csproj b/src/KitchenService/KitchenService.csproj
index e1cb375..df72a05 100644
--- a/src/KitchenService/KitchenService.csproj
+++ b/src/KitchenService/KitchenService.csproj
@@ -4,6 +4,7 @@
net10.0
enable
enable
+ 8dfc5432-10ab-4c89-b8e1-a2f3c4d5e6f7
diff --git a/src/KitchenService/Program.cs b/src/KitchenService/Program.cs
index fb40932..2d949ed 100644
--- a/src/KitchenService/Program.cs
+++ b/src/KitchenService/Program.cs
@@ -1,9 +1,36 @@
+using System.IdentityModel.Tokens.Jwt;
using A2A;
using A2A.AspNetCore;
using KitchenService.Agents;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Identity.Web;
+using Microsoft.IdentityModel.Logging;
var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddMicrosoftIdentityWebApi(options =>
+ {
+ builder.Configuration.Bind("AzureAd", options);
+ options.TokenValidationParameters.ValidateIssuer = true;
+ options.TokenValidationParameters.ValidateAudience = true;
+ options.TokenValidationParameters.ValidateLifetime = true;
+ options.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(5);
+ options.TokenValidationParameters.NameClaimType = "name";
+ options.TokenValidationParameters.RoleClaimType = "role";
+ }, options =>
+ {
+ builder.Configuration.Bind("AzureAd", options);
+ });
+builder.Services.AddAuthorization(options =>
+{
+ options.AddPolicy("AdminOnly", policy =>
+ {
+ policy.RequireAuthenticatedUser();
+ policy.RequireClaim("http://schemas.microsoft.com/identity/claims/scope", "admin");
+ });
+});
+
builder.Services.AddHttpContextAccessor();
// Register TaskManager as singleton
@@ -21,11 +48,20 @@
var app = builder.Build();
+JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
+if (app.Environment.IsDevelopment())
+{
+ IdentityModelEventSource.ShowPII = true;
+}
+
+app.UseAuthentication();
+app.UseAuthorization();
+
var taskManager = app.Services.GetRequiredService();
-// Map A2A endpoints
-app.MapA2A(taskManager, "/");
-app.MapHttpA2A(taskManager, "/");
+// Map A2A endpoints with authentication requirement
+app.MapA2A(taskManager, "/").RequireAuthorization("AdminOnly");
+app.MapHttpA2A(taskManager, "/").RequireAuthorization("AdminOnly");
app.MapDefaultEndpoints();
diff --git a/src/ProductCatalogService/ProductCatalogService.csproj b/src/ProductCatalogService/ProductCatalogService.csproj
index 41151b4..4942d13 100644
--- a/src/ProductCatalogService/ProductCatalogService.csproj
+++ b/src/ProductCatalogService/ProductCatalogService.csproj
@@ -4,6 +4,7 @@
net10.0
enable
enable
+ 43a3b3b6-0841-4645-8e92-aa7b20b60d38
diff --git a/src/ProductCatalogService/Program.cs b/src/ProductCatalogService/Program.cs
index efdd532..a16294e 100644
--- a/src/ProductCatalogService/Program.cs
+++ b/src/ProductCatalogService/Program.cs
@@ -1,7 +1,19 @@
+using Microsoft.Identity.Web;
using ProductCatalogService.Tools;
var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration, "AzureAd");
+
+builder.Services.AddAuthorization(options =>
+{
+ options.AddPolicy("AdminOnly", policy =>
+ {
+ policy.RequireAuthenticatedUser();
+ policy.RequireClaim("http://schemas.microsoft.com/identity/claims/scope", "admin");
+ });
+});
+
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools();
@@ -10,8 +22,11 @@
var app = builder.Build();
-app.MapDefaultEndpoints();
+app.UseAuthentication();
+app.UseAuthorization();
-app.MapMcp("/mcp");
+app.MapMcp("/mcp").RequireAuthorization("AdminOnly");
+
+app.MapDefaultEndpoints();
app.Run();
diff --git a/src/ProductCatalogService/Tools/McpTools.cs b/src/ProductCatalogService/Tools/McpTools.cs
index 4834890..4b0070a 100644
--- a/src/ProductCatalogService/Tools/McpTools.cs
+++ b/src/ProductCatalogService/Tools/McpTools.cs
@@ -25,7 +25,7 @@ public enum ItemType
public record ItemTypeDto(ItemType ItemType, string Name, float Price);
[McpServerToolType]
-public sealed class McpTools
+public sealed class McpTools(ILogger logger)
{
private List itemTypeDtos = Enum.GetValues()
.Select(itemType => new ItemTypeDto(itemType, itemType.ToString().Replace('_', ' '), 3.5f))
@@ -34,6 +34,7 @@ public sealed class McpTools
[McpServerTool, Description("Get item types.")]
public string GetItemType()
{
+ logger.LogInformation("[GetItemType] is called.");
return JsonSerializer.Serialize(itemTypeDtos, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -44,6 +45,9 @@ public string GetItemType()
[McpServerTool, Description("Get item price based on the item type.")]
public string GetItemPrice(ItemType itemType)
{
- return itemTypeDtos.FirstOrDefault(i => i.ItemType == itemType)?.Price.ToString() ?? "0.0";
+ logger.LogInformation("[GetItemPrice] itemType: {itemType}.", itemType);
+ var response = itemTypeDtos.FirstOrDefault(i => i.ItemType == itemType)?.Price.ToString() ?? "0.0";
+ logger.LogInformation("[GetItemPrice] itemTypeDto: {itemTypeDto}.", response);
+ return response;
}
}
diff --git a/src/ServiceDefaults/AgentConstants.cs b/src/ServiceDefaults/AgentConstants.cs
new file mode 100644
index 0000000..bbcd6a2
--- /dev/null
+++ b/src/ServiceDefaults/AgentConstants.cs
@@ -0,0 +1,89 @@
+namespace ServiceDefaults;
+
+///
+/// Shared constants for agent services to eliminate magic strings and improve maintainability.
+/// Reference: Clean Code by Robert Martin - Chapter 17: Smells and Heuristics (G25: Replace Magic Numbers with Named Constants)
+///
+public static class AgentConstants
+{
+ ///
+ /// Agent type identifiers for downstream service configuration
+ ///
+ public static class AgentTypes
+ {
+ public const string Barista = "BARISTA";
+ public const string Kitchen = "KITCHEN";
+ public const string Counter = "COUNTER";
+ }
+
+ ///
+ /// Configuration section keys for agent services
+ ///
+ public static class ConfigurationKeys
+ {
+ public const string BaristaServiceKey = "BaristaService:Key";
+ public const string BaristaServiceUrl = "BaristaService:Url";
+ public const string KitchenServiceKey = "KitchenService:Key";
+ public const string KitchenServiceUrl = "KitchenService:Url";
+ public const string McpServerUrl = "McpServer:Url";
+ public const string McpServerClientName = "McpServer:ClientName";
+ }
+
+ ///
+ /// Default values for agent configurations
+ ///
+ public static class Defaults
+ {
+ public const string BaristaServiceUrl = "https+http://barista";
+ public const string KitchenServiceUrl = "https+http://kitchen";
+ public const string ProductServiceUrl = "https+http://product";
+ public const string McpServerUrl = "https+http://product/mcp";
+ public const string McpServerClientName = "product-catalog-service";
+ }
+
+ ///
+ /// Azure AD configuration keys for authentication
+ ///
+ public static class AzureAdKeys
+ {
+ public const string Instance = "AzureAd:Instance";
+ public const string TenantId = "AzureAd:TenantId";
+ public const string ClientId = "AzureAd:ClientId";
+ public const string Scope = "AzureAd:Scope";
+ }
+
+ ///
+ /// Authentication policy names
+ ///
+ public static class AuthenticationPolicies
+ {
+ public const string AdminOnly = "AdminOnly";
+ public const string RequiredScope = "http://schemas.microsoft.com/identity/claims/scope";
+ public const string RequiredScopeValue = "admin";
+ }
+
+ ///
+ /// Common error messages for consistent error handling
+ ///
+ public static class ErrorMessages
+ {
+ public const string TaskManagerNotAttached = "TaskManager is not attached.";
+ public const string NoMessageContent = "No message content found in task";
+ public const string NoTextContent = "No text content found in message";
+ public const string TaskProcessingCancelled = "Task processing cancelled for ID: {0}";
+ public const string UnexpectedResponseType = "Unexpected response type from A2A protocol";
+ public const string UserNotAuthenticated = "User is not authenticated";
+ public const string AuthenticationRequired = "Authentication is required. Please provide a valid JWT token.";
+ public const string MissingAuthenticationInfo = "Missing authentication information";
+ }
+
+ ///
+ /// Activity source names for OpenTelemetry tracing
+ ///
+ public static class ActivitySources
+ {
+ public const string Counter = "A2A.CounterAgent";
+ public const string Barista = "A2A.BaristaAgent";
+ public const string Kitchen = "A2A.KitchenAgent";
+ }
+}
\ No newline at end of file
diff --git a/src/ServiceDefaults/Agents/BaseAgent.cs b/src/ServiceDefaults/Agents/BaseAgent.cs
new file mode 100644
index 0000000..1f26560
--- /dev/null
+++ b/src/ServiceDefaults/Agents/BaseAgent.cs
@@ -0,0 +1,246 @@
+using System.Diagnostics;
+using A2A;
+using Microsoft.Extensions.Logging;
+using Microsoft.AspNetCore.Http;
+
+namespace ServiceDefaults.Agents;
+
+///
+/// Abstract base class for all agent implementations.
+/// Implements the Template Method Pattern to provide common task processing logic while allowing
+/// specialized implementations to override specific behavior.
+/// Follows DRY principle by consolidating common agent functionality.
+///
+/// Now includes Azure AD authentication integration for secure agent communication.
+/// All agents require Microsoft Entra ID JWT Bearer token authentication with 'access_as_user' scope.
+///
+/// Reference: Clean Code by Robert Martin - Chapter 14: Successive Refinement
+/// Reference: Gang of Four Design Patterns - Template Method Pattern
+/// Reference: .NET Security Best Practices - https://docs.microsoft.com/en-us/dotnet/standard/security/
+///
+public abstract class BaseAgent : IAgent, ITaskProcessor, IAgentCardProvider
+{
+ protected ITaskManager? _taskManager;
+ protected readonly ILogger Logger;
+ protected readonly ActivitySource ActivitySource;
+ protected readonly IHttpContextAccessor HttpContextAccessor;
+
+ protected BaseAgent(ILogger logger, string activitySourceName, IHttpContextAccessor httpContextAccessor)
+ {
+ Logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ ActivitySource = new ActivitySource(activitySourceName, "1.0.0");
+ HttpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
+ }
+
+ ///
+ /// Attaches the agent to a task manager
+ ///
+ public virtual void Attach(ITaskManager taskManager)
+ {
+ _taskManager = taskManager;
+ _taskManager.OnTaskCreated = OnTaskCreatedAsync;
+ _taskManager.OnTaskUpdated = OnTaskUpdatedAsync;
+ _taskManager.OnAgentCardQuery = GetAgentCardAsync;
+ }
+
+ ///
+ /// Handles task creation events with common logging and processing
+ ///
+ protected virtual async Task OnTaskCreatedAsync(AgentTask task, CancellationToken cancellationToken)
+ {
+ using var activity = ActivitySource.StartActivity("OnTaskCreated", ActivityKind.Server);
+ activity?.SetTag("task.id", task.Id);
+
+ Logger.LogInformation("Task created with ID: {TaskId}", task.Id);
+ await ProcessTaskAsync(task, cancellationToken);
+ }
+
+ ///
+ /// Handles task update events with common logging and processing
+ ///
+ protected virtual async Task OnTaskUpdatedAsync(AgentTask task, CancellationToken cancellationToken)
+ {
+ using var activity = ActivitySource.StartActivity("OnTaskUpdated", ActivityKind.Server);
+ activity?.SetTag("task.id", task.Id);
+
+ Logger.LogInformation("Task updated with ID: {TaskId}", task.Id);
+ await ProcessTaskAsync(task, cancellationToken);
+ }
+
+ ///
+ /// Template method for task processing - provides common error handling and validation
+ /// while delegating specific processing logic to derived classes.
+ /// Now includes authentication validation for secure task processing.
+ ///
+ public virtual async Task ProcessTaskAsync(AgentTask task, CancellationToken cancellationToken)
+ {
+ using var activity = ActivitySource.StartActivity("ProcessTask", ActivityKind.Server);
+ activity?.SetTag("task.id", task.Id);
+
+ if (_taskManager == null)
+ {
+ throw new InvalidOperationException(AgentConstants.ErrorMessages.TaskManagerNotAttached);
+ }
+
+ if (cancellationToken.IsCancellationRequested)
+ {
+ Logger.LogWarning(AgentConstants.ErrorMessages.TaskProcessingCancelled, task.Id);
+ return;
+ }
+
+ // Authentication validation
+ var authValidationResult = await ValidateAuthenticationAsync(task, cancellationToken);
+ if (!authValidationResult.IsAuthenticated)
+ {
+ return; // Error already handled in ValidateAuthenticationAsync
+ }
+
+ try
+ {
+ // Template method: delegate specific processing to derived classes
+ await ProcessTaskCoreAsync(task, cancellationToken);
+
+ Logger.LogInformation("Task {TaskId} completed successfully", task.Id);
+ }
+ catch (Exception ex)
+ {
+ await HandleTaskErrorAsync(task, ex, cancellationToken);
+ }
+ }
+
+ ///
+ /// Validates authentication for the current request.
+ /// Returns authentication details including JWT token for downstream service calls.
+ ///
+ protected virtual async Task ValidateAuthenticationAsync(AgentTask task, CancellationToken cancellationToken)
+ {
+ var httpContext = HttpContextAccessor.HttpContext;
+ if (httpContext?.User?.Identity?.IsAuthenticated != true)
+ {
+ await _taskManager!.UpdateStatusAsync(
+ task.Id,
+ TaskState.AuthRequired,
+ new AgentMessage
+ {
+ Parts = [new TextPart { Text = AgentConstants.ErrorMessages.UserNotAuthenticated }]
+ },
+ final: true,
+ cancellationToken: cancellationToken);
+ return new AuthenticationResult { IsAuthenticated = false };
+ }
+
+ var authHeader = httpContext.Request.Headers["Authorization"].FirstOrDefault();
+ string? jwtToken = null;
+ if (authHeader != null && authHeader.StartsWith("Bearer "))
+ {
+ jwtToken = authHeader.Substring("Bearer ".Length).Trim();
+ }
+
+ var userEmail = httpContext.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn")?.Value;
+
+ if (string.IsNullOrEmpty(jwtToken) || string.IsNullOrEmpty(userEmail))
+ {
+ await _taskManager!.UpdateStatusAsync(
+ task.Id,
+ TaskState.AuthRequired,
+ new AgentMessage
+ {
+ Parts = [new TextPart { Text = $"Missing authentication information - JWT token: {(jwtToken != null ? "present" : "missing")}, User email: {(userEmail != null ? "present" : "missing")}" }]
+ },
+ final: true,
+ cancellationToken: cancellationToken);
+ return new AuthenticationResult { IsAuthenticated = false };
+ }
+
+ return new AuthenticationResult
+ {
+ IsAuthenticated = true,
+ JwtToken = jwtToken,
+ UserEmail = userEmail
+ };
+ }
+
+ ///
+ /// Abstract method for core task processing logic - must be implemented by derived classes
+ /// This implements the Template Method pattern
+ ///
+ protected abstract Task ProcessTaskCoreAsync(AgentTask task, CancellationToken cancellationToken);
+
+ ///
+ /// Common error handling for all agents with secure error reporting
+ /// Follows security best practice of not exposing sensitive error details
+ /// Reference: .NET Security Best Practices - https://docs.microsoft.com/en-us/dotnet/standard/security/
+ ///
+ protected virtual async Task HandleTaskErrorAsync(AgentTask task, Exception ex, CancellationToken cancellationToken)
+ {
+ // Log full exception details for debugging (logs should be secure)
+ Logger.LogError(ex, "Error processing task {TaskId}", task.Id);
+
+ // Return sanitized error message to prevent information disclosure
+ var errorMessage = GetSanitizedErrorMessage(ex);
+
+ await _taskManager!.UpdateStatusAsync(
+ task.Id,
+ TaskState.Failed,
+ new AgentMessage
+ {
+ Parts = [new TextPart { Text = errorMessage }]
+ },
+ final: true,
+ cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// Sanitizes error messages to prevent sensitive information disclosure
+ ///
+ protected virtual string GetSanitizedErrorMessage(Exception ex)
+ {
+ // Return generic error message to prevent information disclosure
+ // Log specific details separately for debugging
+ return "An error occurred while processing the request. Please try again later.";
+ }
+
+ ///
+ /// Abstract method for getting agent card - must be implemented by derived classes
+ ///
+ public abstract Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken);
+
+ ///
+ /// Common agent capabilities used by most agents
+ ///
+ protected virtual AgentCapabilities GetDefaultCapabilities()
+ {
+ return new AgentCapabilities
+ {
+ Streaming = true,
+ PushNotifications = false,
+ };
+ }
+
+ ///
+ /// Dispose pattern for ActivitySource cleanup
+ ///
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ ActivitySource?.Dispose();
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+}
+
+///
+/// Result of authentication validation containing authentication status and user details
+///
+public class AuthenticationResult
+{
+ public bool IsAuthenticated { get; set; }
+ public string? JwtToken { get; set; }
+ public string? UserEmail { get; set; }
+}
\ No newline at end of file
diff --git a/src/ServiceDefaults/Agents/IAgent.cs b/src/ServiceDefaults/Agents/IAgent.cs
new file mode 100644
index 0000000..d9abdc3
--- /dev/null
+++ b/src/ServiceDefaults/Agents/IAgent.cs
@@ -0,0 +1,47 @@
+using A2A;
+
+namespace ServiceDefaults.Agents;
+
+///
+/// Base interface for all agent implementations.
+/// Implements Interface Segregation Principle (ISP) by keeping the interface focused and minimal.
+/// Reference: SOLID Principles in C# - https://docs.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/architectural-principles#solid
+///
+public interface IAgent
+{
+ ///
+ /// Attaches the agent to a task manager for task processing
+ ///
+ /// The task manager instance
+ void Attach(ITaskManager taskManager);
+}
+
+///
+/// Interface for task processing operations
+/// Follows Single Responsibility Principle (SRP) by separating task processing concerns
+///
+public interface ITaskProcessor
+{
+ ///
+ /// Processes an agent task asynchronously
+ ///
+ /// The task to process
+ /// Cancellation token
+ /// Task representing the async operation
+ Task ProcessTaskAsync(AgentTask task, CancellationToken cancellationToken);
+}
+
+///
+/// Interface for agent card operations
+/// Follows Interface Segregation Principle (ISP) by separating agent card concerns
+///
+public interface IAgentCardProvider
+{
+ ///
+ /// Gets the agent card for the agent
+ ///
+ /// The agent URL
+ /// Cancellation token
+ /// The agent card
+ Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken);
+}
\ No newline at end of file
diff --git a/src/ServiceDefaults/Agents/SimpleAgent.cs b/src/ServiceDefaults/Agents/SimpleAgent.cs
new file mode 100644
index 0000000..0ff13ca
--- /dev/null
+++ b/src/ServiceDefaults/Agents/SimpleAgent.cs
@@ -0,0 +1,88 @@
+using A2A;
+using Microsoft.Extensions.Logging;
+using Microsoft.AspNetCore.Http;
+
+namespace ServiceDefaults.Agents;
+
+///
+/// Simple agent implementation for basic task processing (used by Barista and Kitchen services).
+/// This consolidates the common functionality that was duplicated between BaristaAgent and KitchenAgent.
+/// Follows DRY principle by eliminating code duplication.
+/// Reference: Clean Code by Robert Martin - Chapter 17: Smells and Heuristics (G5: Duplication)
+///
+public class SimpleAgent : BaseAgent
+{
+ private readonly string _agentName;
+ private readonly string _agentDescription;
+ private readonly string _skillName;
+ private readonly string _skillDescription;
+
+ public SimpleAgent(
+ ILogger logger,
+ string activitySourceName,
+ IHttpContextAccessor httpContextAccessor,
+ string agentName,
+ string agentDescription,
+ string skillName = "process_order",
+ string skillDescription = "Process messages and communicate with MCP server for admin users. Requires JWT authentication with admin role and 'access_as_user' scope.")
+ : base(logger, activitySourceName, httpContextAccessor)
+ {
+ _agentName = agentName;
+ _agentDescription = agentDescription;
+ _skillName = skillName;
+ _skillDescription = skillDescription;
+ }
+
+ ///
+ /// Core task processing for simple agents - completes tasks with success message
+ ///
+ protected override async Task ProcessTaskCoreAsync(AgentTask task, CancellationToken cancellationToken)
+ {
+ // Complete the task with a success message
+ await _taskManager!.UpdateStatusAsync(
+ task.Id,
+ TaskState.Completed,
+ new AgentMessage
+ {
+ Parts = [new TextPart { Text = "Message processed successfully" }]
+ },
+ final: true,
+ cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// Gets the agent card with configurable details
+ ///
+ public override Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return Task.FromCanceled(cancellationToken);
+ }
+
+ var capabilities = GetDefaultCapabilities();
+
+ // Note: Authentication is implemented at the HTTP transport level using Microsoft Entra ID
+ // JWT Bearer tokens are required for all endpoints and are validated by the middleware
+ // The authentication scheme used is "Bearer" with JWT tokens containing required scopes
+ return Task.FromResult(new AgentCard
+ {
+ Name = _agentName,
+ Description = $"{_agentDescription} " +
+ "AUTHENTICATION REQUIRED: This agent requires Microsoft Entra ID JWT Bearer token authentication " +
+ "with 'access_as_user' scope. All requests must include valid JWT tokens in the Authorization header.",
+ Url = agentUrl,
+ Version = "1.0.0",
+ DefaultInputModes = ["text"],
+ DefaultOutputModes = ["text"],
+ Capabilities = capabilities,
+ Skills = [
+ new AgentSkill
+ {
+ Name = _skillName,
+ Description = _skillDescription
+ }
+ ],
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/ServiceDefaults/Configuration/AgentConfigurationService.cs b/src/ServiceDefaults/Configuration/AgentConfigurationService.cs
new file mode 100644
index 0000000..a60d764
--- /dev/null
+++ b/src/ServiceDefaults/Configuration/AgentConfigurationService.cs
@@ -0,0 +1,109 @@
+using Microsoft.Extensions.Configuration;
+
+namespace ServiceDefaults.Configuration;
+
+///
+/// Service for validating and loading agent configurations securely.
+/// Implements security best practice of validating configuration inputs.
+/// Reference: .NET Security Best Practices - https://docs.microsoft.com/en-us/dotnet/standard/security/
+///
+public interface IAgentConfigurationService
+{
+ ///
+ /// Gets validated downstream agent endpoints
+ ///
+ Dictionary GetDownstreamAgentEndpoints();
+
+ ///
+ /// Gets MCP server configuration
+ ///
+ McpServerConfiguration GetMcpServerConfiguration();
+
+ ///
+ /// Validates all configuration values
+ ///
+ /// Validation result with any errors
+ ConfigurationValidationResult ValidateConfiguration();
+}
+
+///
+/// MCP server configuration
+///
+public record McpServerConfiguration(string Url, string ClientName);
+
+///
+/// Configuration validation result
+///
+public record ConfigurationValidationResult(bool IsValid, List Errors);
+
+///
+/// Implementation of agent configuration service with validation
+///
+public class AgentConfigurationService : IAgentConfigurationService
+{
+ private readonly IConfiguration _configuration;
+
+ public AgentConfigurationService(IConfiguration configuration)
+ {
+ _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
+ }
+
+ public Dictionary GetDownstreamAgentEndpoints()
+ {
+ var endpoints = new Dictionary
+ {
+ {
+ _configuration[AgentConstants.ConfigurationKeys.BaristaServiceKey] ?? AgentConstants.AgentTypes.Barista,
+ _configuration[AgentConstants.ConfigurationKeys.BaristaServiceUrl] ?? AgentConstants.Defaults.BaristaServiceUrl
+ },
+ {
+ _configuration[AgentConstants.ConfigurationKeys.KitchenServiceKey] ?? AgentConstants.AgentTypes.Kitchen,
+ _configuration[AgentConstants.ConfigurationKeys.KitchenServiceUrl] ?? AgentConstants.Defaults.KitchenServiceUrl
+ }
+ };
+
+ return endpoints;
+ }
+
+ public McpServerConfiguration GetMcpServerConfiguration()
+ {
+ var url = _configuration[AgentConstants.ConfigurationKeys.McpServerUrl] ?? AgentConstants.Defaults.McpServerUrl;
+ var clientName = _configuration[AgentConstants.ConfigurationKeys.McpServerClientName] ?? AgentConstants.Defaults.McpServerClientName;
+
+ return new McpServerConfiguration(url, clientName);
+ }
+
+ public ConfigurationValidationResult ValidateConfiguration()
+ {
+ var errors = new List();
+
+ // Validate URLs
+ var endpoints = GetDownstreamAgentEndpoints();
+ foreach (var (key, url) in endpoints)
+ {
+ if (!IsValidUrl(url))
+ {
+ errors.Add($"Invalid URL for agent {key}: {url}");
+ }
+ }
+
+ var mcpConfig = GetMcpServerConfiguration();
+ if (!IsValidUrl(mcpConfig.Url))
+ {
+ errors.Add($"Invalid MCP server URL: {mcpConfig.Url}");
+ }
+
+ if (string.IsNullOrWhiteSpace(mcpConfig.ClientName))
+ {
+ errors.Add("MCP client name is required");
+ }
+
+ return new ConfigurationValidationResult(errors.Count == 0, errors);
+ }
+
+ private static bool IsValidUrl(string url)
+ {
+ return Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
+ (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
+ }
+}
\ No newline at end of file
diff --git a/src/ServiceDefaults/Extensions/ServiceCollectionExtensions.cs b/src/ServiceDefaults/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..ca41c26
--- /dev/null
+++ b/src/ServiceDefaults/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,59 @@
+using ServiceDefaults.Agents;
+using ServiceDefaults.Configuration;
+using ServiceDefaults.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ServiceDefaults.Extensions;
+
+///
+/// Extension methods for registering agent services with dependency injection.
+/// Implements Dependency Inversion Principle (DIP) by providing centralized service registration.
+/// Reference: SOLID Principles in C# - https://docs.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/architectural-principles#solid
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Adds all agent-related services to the dependency injection container
+ ///
+ /// The service collection
+ /// The service collection for method chaining
+ public static IServiceCollection AddAgentServices(this IServiceCollection services)
+ {
+ // Add HTTP context accessor for authentication
+ services.AddHttpContextAccessor();
+
+ // Configuration services
+ services.AddSingleton();
+
+ // Core agent services
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+
+ ///
+ /// Adds simple agent services for Barista and Kitchen services
+ ///
+ /// The service collection
+ /// The service collection for method chaining
+ public static IServiceCollection AddSimpleAgentServices(this IServiceCollection services)
+ {
+ services.AddAgentServices();
+ return services;
+ }
+
+ ///
+ /// Adds counter agent services with all dependencies
+ ///
+ /// The service collection
+ /// The service collection for method chaining
+ public static IServiceCollection AddCounterAgentServices(this IServiceCollection services)
+ {
+ services.AddAgentServices();
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/src/ServiceDefaults/Models/A2AServiceResponse.cs b/src/ServiceDefaults/Models/A2AServiceResponse.cs
new file mode 100644
index 0000000..c4fb4cb
--- /dev/null
+++ b/src/ServiceDefaults/Models/A2AServiceResponse.cs
@@ -0,0 +1,38 @@
+namespace ServiceDefaults.Models;
+
+///
+/// Standardized response model for A2A service operations
+///
+public class A2AServiceResponse
+{
+ public bool Success { get; set; }
+ public string Message { get; set; } = string.Empty;
+ public object? Data { get; set; }
+ public string? Error { get; set; }
+ public A2AResponseData A2AResponse { get; set; } = new();
+ public McpResponseData McpResponse { get; set; } = new();
+}
+
+///
+/// Data model for A2A response details
+///
+public class A2AResponseData
+{
+ public bool Success { get; set; }
+ public string Message { get; set; } = string.Empty;
+ public string? Protocol { get; set; }
+ public DateTime Timestamp { get; set; }
+}
+
+///
+/// Data model for MCP response details
+///
+public class McpResponseData
+{
+ public bool Success { get; set; }
+ public bool ToolExecuted { get; set; }
+ public bool AdminAccess { get; set; }
+ public string? ErrorMessage { get; set; }
+ public string? ResponseContent { get; set; }
+ public DateTime Timestamp { get; set; }
+}
\ No newline at end of file
diff --git a/src/ServiceDefaults/ServiceDefaults.csproj b/src/ServiceDefaults/ServiceDefaults.csproj
index 8128a10..04b63a0 100644
--- a/src/ServiceDefaults/ServiceDefaults.csproj
+++ b/src/ServiceDefaults/ServiceDefaults.csproj
@@ -5,6 +5,7 @@
enable
enable
true
+ $(NoWarn);SKEXP0001;SKEXP0010;SKEXP0110
@@ -17,6 +18,12 @@
+
+
+
+
+
+
diff --git a/src/ServiceDefaults/Services/A2AClientManager.cs b/src/ServiceDefaults/Services/A2AClientManager.cs
new file mode 100644
index 0000000..1e9e6cf
--- /dev/null
+++ b/src/ServiceDefaults/Services/A2AClientManager.cs
@@ -0,0 +1,125 @@
+using A2A;
+using ServiceDefaults.Configuration;
+using Microsoft.Extensions.Logging;
+using System.Diagnostics;
+
+namespace ServiceDefaults.Services;
+
+///
+/// Interface for A2A client management operations.
+/// Follows Single Responsibility Principle (SRP) by focusing only on A2A client management.
+/// Now includes support for authenticated HTTP clients with JWT tokens.
+/// Reference: SOLID Principles in C# - https://docs.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/architectural-principles#solid
+///
+public interface IA2AClientManager
+{
+ ///
+ /// Initializes A2A clients for downstream services with authentication
+ ///
+ /// JWT token for authenticated requests
+ /// Cancellation token
+ Task InitializeClientsAsync(string? jwtToken = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets A2A clients by their keys
+ ///
+ IReadOnlyDictionary GetClients();
+
+ ///
+ /// Gets a specific A2A client by key
+ ///
+ /// The agent key
+ /// The A2A client or null if not found
+ A2AClient? GetClient(string key);
+}
+
+///
+/// Service for managing A2A clients and their connections.
+/// Implements Dependency Inversion Principle (DIP) by depending on abstractions.
+///
+public class A2AClientManager : IA2AClientManager
+{
+ private readonly IAgentConfigurationService _configurationService;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ILogger _logger;
+ private readonly Dictionary _clients = new();
+ private readonly ActivitySource _activitySource = new("A2A.ClientManager", "1.0.0");
+
+ public A2AClientManager(
+ IAgentConfigurationService configurationService,
+ IHttpClientFactory httpClientFactory,
+ ILogger logger)
+ {
+ _configurationService = configurationService ?? throw new ArgumentNullException(nameof(configurationService));
+ _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task InitializeClientsAsync(string? jwtToken = null, CancellationToken cancellationToken = default)
+ {
+ using var activity = _activitySource.StartActivity("InitializeClients");
+
+ var endpoints = _configurationService.GetDownstreamAgentEndpoints();
+
+ foreach (var (key, endpoint) in endpoints)
+ {
+ try
+ {
+ using var clientActivity = _activitySource.StartActivity("InitializeClient");
+ clientActivity?.SetTag("agent.key", key);
+ clientActivity?.SetTag("agent.endpoint", endpoint);
+
+ _logger.LogDebug("Initializing A2A client for agent: {AgentKey} at {Endpoint}", key, endpoint);
+
+ // Create authenticated HTTP client with JWT token
+ var httpClient = _httpClientFactory.CreateClient();
+ httpClient.DefaultRequestHeaders.Clear();
+
+ if (!string.IsNullOrEmpty(jwtToken))
+ {
+ httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {jwtToken}");
+ _logger.LogDebug("Added JWT authentication header for agent: {AgentKey}", key);
+ }
+
+ var cardResolver = new A2ACardResolver(new Uri(endpoint), httpClient: httpClient);
+ var agentCard = await cardResolver.GetAgentCardAsync();
+
+ clientActivity?.SetTag("agent.card.url", agentCard.Url);
+
+ _logger.LogDebug("Resolved agent card for {AgentKey}: {AgentUrl}", key, agentCard.Url);
+
+ var client = new A2AClient(new Uri(agentCard.Url), httpClient);
+
+ if (!_clients.ContainsKey(key))
+ {
+ _clients.Add(key, client);
+ _logger.LogInformation("Successfully initialized A2A client for agent: {AgentKey}", key);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to initialize A2A client for agent: {AgentKey} at {Endpoint}", key, endpoint);
+ // Continue with other clients even if one fails
+ }
+ }
+
+ activity?.SetTag("clients.count", _clients.Count);
+ _logger.LogInformation("A2A client initialization completed. {ClientCount} clients ready", _clients.Count);
+ }
+
+ public IReadOnlyDictionary GetClients()
+ {
+ return _clients.AsReadOnly();
+ }
+
+ public A2AClient? GetClient(string key)
+ {
+ _clients.TryGetValue(key, out var client);
+ return client;
+ }
+
+ public void Dispose()
+ {
+ _activitySource?.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/src/ServiceDefaults/Services/A2AMessageService.cs b/src/ServiceDefaults/Services/A2AMessageService.cs
new file mode 100644
index 0000000..6d126e4
--- /dev/null
+++ b/src/ServiceDefaults/Services/A2AMessageService.cs
@@ -0,0 +1,161 @@
+using System.Text.Json;
+using A2A;
+using ServiceDefaults.Models;
+using Microsoft.Extensions.Logging;
+using System.Diagnostics;
+
+namespace ServiceDefaults.Services;
+
+///
+/// Service for handling A2A message operations.
+/// Follows Single Responsibility Principle (SRP) by focusing only on A2A messaging.
+///
+public interface IA2AMessageService
+{
+ ///
+ /// Sends A2A messages to appropriate downstream services based on order items
+ ///
+ /// The original message text
+ /// The parsed order
+ /// Cancellation token
+ /// List of A2A service responses
+ Task> SendOrderMessagesAsync(string messageText, OrderDto order, CancellationToken cancellationToken = default);
+}
+
+///
+/// Implementation of A2A message service
+///
+public class A2AMessageService : IA2AMessageService
+{
+ private readonly IA2AClientManager _clientManager;
+ private readonly IA2AResponseMapper _responseMapper;
+ private readonly ILogger _logger;
+ private readonly ActivitySource _activitySource = new("A2A.MessageService", "1.0.0");
+
+ public A2AMessageService(
+ IA2AClientManager clientManager,
+ IA2AResponseMapper responseMapper,
+ ILogger logger)
+ {
+ _clientManager = clientManager ?? throw new ArgumentNullException(nameof(clientManager));
+ _responseMapper = responseMapper ?? throw new ArgumentNullException(nameof(responseMapper));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task> SendOrderMessagesAsync(string messageText, OrderDto order, CancellationToken cancellationToken = default)
+ {
+ using var activity = _activitySource.StartActivity("SendOrderMessages");
+ var responses = new List();
+
+ var clientOrders = GetClientOrderMapping(order);
+ activity?.SetTag("clients.count", clientOrders.Count);
+
+ _logger.LogInformation("Sending A2A messages to {ClientCount} downstream services", clientOrders.Count);
+
+ foreach (var (client, items) in clientOrders)
+ {
+ try
+ {
+ var response = await SendMessageToClient(client, messageText, items, cancellationToken);
+ responses.Add(response);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to send A2A message to client");
+ responses.Add(new A2AServiceResponse
+ {
+ Success = false,
+ Message = "Failed to send A2A message",
+ Error = "Communication error with downstream service"
+ });
+ }
+ }
+
+ return responses;
+ }
+
+ private Dictionary> GetClientOrderMapping(OrderDto order)
+ {
+ var clientOrders = new Dictionary>();
+ var clients = _clientManager.GetClients();
+
+ // Map barista items
+ if (order.BaristaItems.Count > 0)
+ {
+ var baristaClient = _clientManager.GetClient(AgentConstants.AgentTypes.Barista);
+ if (baristaClient != null)
+ {
+ clientOrders.Add(baristaClient, order.BaristaItems);
+ }
+ else
+ {
+ _logger.LogWarning("Barista client not available for {ItemCount} items", order.BaristaItems.Count);
+ }
+ }
+
+ // Map kitchen items
+ if (order.KitchenItems.Count > 0)
+ {
+ var kitchenClient = _clientManager.GetClient(AgentConstants.AgentTypes.Kitchen);
+ if (kitchenClient != null)
+ {
+ clientOrders.Add(kitchenClient, order.KitchenItems);
+ }
+ else
+ {
+ _logger.LogWarning("Kitchen client not available for {ItemCount} items", order.KitchenItems.Count);
+ }
+ }
+
+ return clientOrders;
+ }
+
+ private async Task SendMessageToClient(A2AClient client, string messageText, List items, CancellationToken cancellationToken)
+ {
+ using var activity = _activitySource.StartActivity("SendMessageToClient");
+ activity?.SetTag("items.count", items.Count);
+
+ // Create A2A message with minimal metadata (authentication is in HTTP headers)
+ var a2aMessage = new AgentMessage
+ {
+ Role = MessageRole.User,
+ MessageId = Guid.NewGuid().ToString(),
+ ContextId = Guid.NewGuid().ToString(),
+ Parts = [new TextPart { Text = messageText }],
+ Metadata = new Dictionary
+ {
+ ["items"] = JsonSerializer.SerializeToElement(items),
+ ["timestamp"] = JsonSerializer.SerializeToElement(DateTime.UtcNow.ToString("O"))
+ }
+ };
+
+ // Create MessageSendParams for A2A protocol
+ var messageSendParams = new MessageSendParams
+ {
+ Message = a2aMessage,
+ Configuration = new MessageSendConfiguration
+ {
+ AcceptedOutputModes = ["text"],
+ Blocking = true
+ }
+ };
+
+ _logger.LogDebug("Sending A2A message with {ItemCount} items", items.Count);
+
+ // Send message via A2A protocol with authenticated HTTP client
+ var a2aResponse = await client.SendMessageAsync(messageSendParams);
+ activity?.SetTag("response.type", a2aResponse?.GetType().Name ?? "null");
+
+ if (a2aResponse == null)
+ {
+ throw new InvalidOperationException("Received null response from A2A client");
+ }
+
+ return _responseMapper.MapResponse(a2aResponse);
+ }
+
+ public void Dispose()
+ {
+ _activitySource?.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/src/ServiceDefaults/Services/A2AResponseMapper.cs b/src/ServiceDefaults/Services/A2AResponseMapper.cs
new file mode 100644
index 0000000..4c91545
--- /dev/null
+++ b/src/ServiceDefaults/Services/A2AResponseMapper.cs
@@ -0,0 +1,90 @@
+using A2A;
+using ServiceDefaults.Models;
+using Microsoft.Extensions.Logging;
+
+namespace ServiceDefaults.Services;
+
+///
+/// Service for mapping A2A responses to standardized response objects.
+/// Follows Single Responsibility Principle (SRP) by focusing only on response mapping.
+///
+public interface IA2AResponseMapper
+{
+ ///
+ /// Maps an A2A response to a standardized service response
+ ///
+ /// The A2A response to map
+ /// Mapped service response
+ A2AServiceResponse MapResponse(A2AResponse response);
+}
+
+///
+/// Implementation of A2A response mapper
+///
+public class A2AResponseMapper : IA2AResponseMapper
+{
+ private readonly ILogger _logger;
+
+ public A2AResponseMapper(ILogger logger)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public A2AServiceResponse MapResponse(A2AResponse response)
+ {
+ return response switch
+ {
+ AgentTask task => MapTaskResponse(task),
+ AgentMessage messageResponse => MapMessageResponse(messageResponse),
+ _ => MapUnknownResponse(response)
+ };
+ }
+
+ private A2AServiceResponse MapTaskResponse(AgentTask task)
+ {
+ _logger.LogInformation("A2A task created successfully with ID: {TaskId}, Status: {TaskStatus}",
+ task.Id, task.Status.State.ToString());
+
+ return new A2AServiceResponse
+ {
+ Success = true,
+ Message = "A2A task created successfully",
+ Data = new
+ {
+ TaskId = task.Id,
+ Status = task.Status.State.ToString(),
+ Response = task.Artifacts?.FirstOrDefault()?.Parts?.OfType()?.FirstOrDefault()?.Text ?? "Task created"
+ }
+ };
+ }
+
+ private A2AServiceResponse MapMessageResponse(AgentMessage messageResponse)
+ {
+ _logger.LogInformation("Received A2A message response");
+
+ var responseText = messageResponse.Parts?.OfType()?.FirstOrDefault()?.Text ?? "No response content";
+
+ return new A2AServiceResponse
+ {
+ Success = true,
+ Message = "A2A message sent successfully",
+ Data = new
+ {
+ Response = responseText,
+ MessageId = messageResponse.MessageId
+ }
+ };
+ }
+
+ private A2AServiceResponse MapUnknownResponse(A2AResponse? response)
+ {
+ _logger.LogWarning("Unexpected A2A response type: {ResponseType}", response?.GetType().Name ?? "null");
+
+ return new A2AServiceResponse
+ {
+ Success = false,
+ Message = AgentConstants.ErrorMessages.UnexpectedResponseType,
+ Error = $"Unknown response format: {response?.GetType().Name ?? "null"}"
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/ServiceDefaults/Services/InputValidationService.cs b/src/ServiceDefaults/Services/InputValidationService.cs
new file mode 100644
index 0000000..f8c4571
--- /dev/null
+++ b/src/ServiceDefaults/Services/InputValidationService.cs
@@ -0,0 +1,94 @@
+using A2A;
+
+namespace ServiceDefaults.Services;
+
+///
+/// Service for validating input messages and data.
+/// Implements security best practice of input validation to prevent injection attacks.
+/// Reference: .NET Security Best Practices - https://docs.microsoft.com/en-us/dotnet/standard/security/
+///
+public interface IInputValidationService
+{
+ ///
+ /// Validates an agent task for required content
+ ///
+ /// The task to validate
+ /// Validation result
+ TaskValidationResult ValidateTask(AgentTask task);
+
+ ///
+ /// Sanitizes text input to prevent injection attacks
+ ///
+ /// The input text to sanitize
+ /// Sanitized text
+ string SanitizeTextInput(string input);
+}
+
+///
+/// Task validation result
+///
+public record TaskValidationResult(bool IsValid, string? ErrorMessage, string? TextContent);
+
+///
+/// Implementation of input validation service
+///
+public class InputValidationService : IInputValidationService
+{
+ private const int MaxTextLength = 10000; // Prevent large inputs
+ private static readonly string[] ForbiddenPatterns =
+ {
+ "", "alert('xss')")]
+ [InlineData("Click javascript:void(0)", "Click void(0)")]
+ [InlineData("onclick=\"alert('test')\"", "")]
+ [InlineData("eval(document.cookie)", "document.cookie)")]
+ [InlineData("vbscript:msgbox", "msgbox")]
+ public void SanitizeTextInput_RemovesDangerousPatterns(string input, string expected)
+ {
+ // Act
+ var result = _service.SanitizeTextInput(input);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void SanitizeTextInput_RemovesControlCharacters()
+ {
+ // Arrange
+ var input = "Normal text\x00\x01\x02with\x03control\x04chars\ttab\nline\rreturn";
+ var expected = "Normal textwithcontrolcharstabline\rreturn";
+
+ // Act
+ var result = _service.SanitizeTextInput(input);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void SanitizeTextInput_TrimsWhitespace()
+ {
+ // Arrange
+ var input = " Text with spaces ";
+ var expected = "Text with spaces";
+
+ // Act
+ var result = _service.SanitizeTextInput(input);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void SanitizeTextInput_WithNullInput_ReturnsNull()
+ {
+ // Act
+ var result = _service.SanitizeTextInput(null);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void SanitizeTextInput_WithEmptyInput_ReturnsEmpty()
+ {
+ // Act
+ var result = _service.SanitizeTextInput("");
+
+ // Assert
+ Assert.Equal("", result);
+ }
+
+ [Fact]
+ public void ValidateTask_SanitizesTextContent()
+ {
+ // Arrange
+ var maliciousText = "Order coffee";
+ var expectedSanitized = "alert('xss')Order coffee";
+ var task = new AgentTask
+ {
+ History = new List
+ {
+ new Message
+ {
+ Parts = new List
+ {
+ new TextPart { Text = maliciousText }
+ }
+ }
+ }
+ };
+
+ // Act
+ var result = _service.ValidateTask(task);
+
+ // Assert
+ Assert.True(result.IsValid);
+ Assert.Equal(expectedSanitized, result.TextContent);
+ }
+}
\ No newline at end of file
diff --git a/tests/ServiceDefaults.Tests/Services/OrderParsingServiceTests.cs b/tests/ServiceDefaults.Tests/Services/OrderParsingServiceTests.cs
new file mode 100644
index 0000000..95029cb
--- /dev/null
+++ b/tests/ServiceDefaults.Tests/Services/OrderParsingServiceTests.cs
@@ -0,0 +1,250 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.SemanticKernel;
+using Moq;
+using ServiceDefaults.Configuration;
+using ServiceDefaults.Services;
+
+namespace ServiceDefaults.Tests.Services;
+
+///
+/// Unit tests for OrderParsingService to verify order parsing logic and stub functionality
+///
+public class OrderParsingServiceTests
+{
+ private readonly Mock _mockKernel;
+ private readonly Mock _mockConfigService;
+ private readonly Mock _mockHttpClientFactory;
+ private readonly Mock> _mockLogger;
+ private readonly OrderParsingService _orderParsingService;
+
+ public OrderParsingServiceTests()
+ {
+ _mockKernel = new Mock();
+ _mockConfigService = new Mock();
+ _mockHttpClientFactory = new Mock();
+ _mockLogger = new Mock>();
+
+ _orderParsingService = new OrderParsingService(
+ _mockKernel.Object,
+ _mockConfigService.Object,
+ _mockHttpClientFactory.Object,
+ _mockLogger.Object);
+ }
+
+ [Fact]
+ public void Constructor_WithAllValidDependencies_SucceedsWithoutException()
+ {
+ // Act & Assert - constructor call in setup should succeed
+ Assert.NotNull(_orderParsingService);
+ }
+
+ [Fact]
+ public void Constructor_WithNullKernel_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Assert.Throws(() => new OrderParsingService(
+ null,
+ _mockConfigService.Object,
+ _mockHttpClientFactory.Object,
+ _mockLogger.Object));
+ }
+
+ [Fact]
+ public void Constructor_WithNullConfigurationService_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Assert.Throws(() => new OrderParsingService(
+ _mockKernel.Object,
+ null,
+ _mockHttpClientFactory.Object,
+ _mockLogger.Object));
+ }
+
+ [Fact]
+ public void Constructor_WithNullHttpClientFactory_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Assert.Throws(() => new OrderParsingService(
+ _mockKernel.Object,
+ _mockConfigService.Object,
+ null,
+ _mockLogger.Object));
+ }
+
+ [Fact]
+ public void Constructor_WithNullLogger_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Assert.Throws(() => new OrderParsingService(
+ _mockKernel.Object,
+ _mockConfigService.Object,
+ _mockHttpClientFactory.Object,
+ null));
+ }
+
+ [Fact]
+ public async Task ParseOrderAsync_WithStubMode_ReturnsStubOrder()
+ {
+ // Arrange
+ var messageText = "I want a coffee and a cake";
+
+ // Act
+ var result = await _orderParsingService.ParseOrderAsync(messageText, isStub: true);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.BaristaItems);
+ Assert.NotNull(result.KitchenItems);
+
+ // Verify stub data structure
+ Assert.Equal(2, result.BaristaItems.Count);
+ Assert.Single(result.KitchenItems);
+
+ // Verify barista items
+ var blackCoffee = result.BaristaItems.FirstOrDefault(i => i.Name == "black coffee");
+ Assert.NotNull(blackCoffee);
+ Assert.Equal(ItemType.COFFEE_BLACK, blackCoffee.ItemType);
+ Assert.Equal(3, blackCoffee.Price);
+
+ var cappuccino = result.BaristaItems.FirstOrDefault(i => i.Name == "cappuccino");
+ Assert.NotNull(cappuccino);
+ Assert.Equal(ItemType.CAPPUCCINO, cappuccino.ItemType);
+ Assert.Equal(3.5f, cappuccino.Price);
+
+ // Verify kitchen items
+ var cakePop = result.KitchenItems.First();
+ Assert.Equal("cake pop", cakePop.Name);
+ Assert.Equal(ItemType.CAKEPOP, cakePop.ItemType);
+ Assert.Equal(5, cakePop.Price);
+ }
+
+ [Fact]
+ public async Task ParseOrderAsync_WithEmptyMessage_ReturnsStubOrder()
+ {
+ // Arrange
+ var messageText = "";
+
+ // Act
+ var result = await _orderParsingService.ParseOrderAsync(messageText, isStub: true);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.BaristaItems);
+ Assert.NotNull(result.KitchenItems);
+ Assert.Equal(2, result.BaristaItems.Count);
+ Assert.Single(result.KitchenItems);
+ }
+
+ [Fact]
+ public async Task ParseOrderAsync_WithNullMessage_ReturnsStubOrder()
+ {
+ // Arrange
+ string messageText = null;
+
+ // Act
+ var result = await _orderParsingService.ParseOrderAsync(messageText, isStub: true);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.BaristaItems);
+ Assert.NotNull(result.KitchenItems);
+ Assert.Equal(2, result.BaristaItems.Count);
+ Assert.Single(result.KitchenItems);
+ }
+
+ [Fact]
+ public async Task ParseOrderAsync_WithCancellation_ThrowsOperationCanceledException()
+ {
+ // Arrange
+ var messageText = "I want a coffee";
+ var cancellationToken = new CancellationToken(true);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ () => _orderParsingService.ParseOrderAsync(messageText, isStub: false, cancellationToken));
+ }
+
+ [Theory]
+ [InlineData("black coffee")]
+ [InlineData("cappuccino")]
+ [InlineData("espresso")]
+ [InlineData("latte")]
+ public async Task ParseOrderAsync_WithStubMode_ContainsBeverageTypes(string expectedBeverage)
+ {
+ // Act
+ var result = await _orderParsingService.ParseOrderAsync("test", isStub: true);
+
+ // Assert
+ var hasBeverage = result.BaristaItems.Any(item =>
+ item.Name.Contains(expectedBeverage, StringComparison.OrdinalIgnoreCase));
+
+ if (expectedBeverage == "black coffee" || expectedBeverage == "cappuccino")
+ {
+ Assert.True(hasBeverage, $"Expected to find {expectedBeverage} in barista items");
+ }
+ }
+
+ [Fact]
+ public async Task ParseOrderAsync_WithStubMode_HasValidPrices()
+ {
+ // Act
+ var result = await _orderParsingService.ParseOrderAsync("test", isStub: true);
+
+ // Assert
+ foreach (var item in result.BaristaItems)
+ {
+ Assert.True(item.Price > 0, $"Item {item.Name} should have a positive price");
+ }
+
+ foreach (var item in result.KitchenItems)
+ {
+ Assert.True(item.Price > 0, $"Item {item.Name} should have a positive price");
+ }
+ }
+
+ [Fact]
+ public async Task ParseOrderAsync_WithStubMode_HasValidItemTypes()
+ {
+ // Act
+ var result = await _orderParsingService.ParseOrderAsync("test", isStub: true);
+
+ // Assert
+ foreach (var item in result.BaristaItems)
+ {
+ Assert.True(Enum.IsDefined(typeof(ItemType), item.ItemType),
+ $"Item {item.Name} should have a valid ItemType");
+ }
+
+ foreach (var item in result.KitchenItems)
+ {
+ Assert.True(Enum.IsDefined(typeof(ItemType), item.ItemType),
+ $"Item {item.Name} should have a valid ItemType");
+ }
+ }
+
+ [Fact]
+ public async Task ParseOrderAsync_WithStubMode_ReturnsConsistentData()
+ {
+ // Act
+ var result1 = await _orderParsingService.ParseOrderAsync("message1", isStub: true);
+ var result2 = await _orderParsingService.ParseOrderAsync("message2", isStub: true);
+
+ // Assert - Stub should return consistent data regardless of input
+ Assert.Equal(result1.BaristaItems.Count, result2.BaristaItems.Count);
+ Assert.Equal(result1.KitchenItems.Count, result2.KitchenItems.Count);
+
+ for (int i = 0; i < result1.BaristaItems.Count; i++)
+ {
+ Assert.Equal(result1.BaristaItems[i].Name, result2.BaristaItems[i].Name);
+ Assert.Equal(result1.BaristaItems[i].ItemType, result2.BaristaItems[i].ItemType);
+ Assert.Equal(result1.BaristaItems[i].Price, result2.BaristaItems[i].Price);
+ }
+
+ for (int i = 0; i < result1.KitchenItems.Count; i++)
+ {
+ Assert.Equal(result1.KitchenItems[i].Name, result2.KitchenItems[i].Name);
+ Assert.Equal(result1.KitchenItems[i].ItemType, result2.KitchenItems[i].ItemType);
+ Assert.Equal(result1.KitchenItems[i].Price, result2.KitchenItems[i].Price);
+ }
+ }
+}
\ No newline at end of file