diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..7894fed44 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "aspire.enableSettingsFileCreationPromptOnStartup": false +} \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 041659c97..9dfa7bdb6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,8 @@ true - + + diff --git a/src/Infrastructure/BotSharp.Core.A2A/BotSharp.Core.A2A.csproj b/src/Infrastructure/BotSharp.Core.A2A/BotSharp.Core.A2A.csproj index c91cc0d54..07a8a6189 100644 --- a/src/Infrastructure/BotSharp.Core.A2A/BotSharp.Core.A2A.csproj +++ b/src/Infrastructure/BotSharp.Core.A2A/BotSharp.Core.A2A.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Infrastructure/BotSharp.Core.A2A/Functions/A2ADelegationFn.cs b/src/Infrastructure/BotSharp.Core.A2A/Functions/A2ADelegationFn.cs index 959267e57..a75edf187 100644 --- a/src/Infrastructure/BotSharp.Core.A2A/Functions/A2ADelegationFn.cs +++ b/src/Infrastructure/BotSharp.Core.A2A/Functions/A2ADelegationFn.cs @@ -25,11 +25,12 @@ public A2ADelegationFn(IA2AService a2aService, A2ASettings settings, IConversati public async Task Execute(RoleDialogModel message) { - var args = JsonSerializer.Deserialize(message.FunctionArgs); + var rawArgs = string.IsNullOrWhiteSpace(message.FunctionArgs) ? "{}" : message.FunctionArgs; + var args = JsonSerializer.Deserialize(rawArgs); string queryText = string.Empty; if (args.TryGetProperty("user_query", out var queryProp)) { - queryText = queryProp.GetString(); + queryText = queryProp.GetString() ?? string.Empty; } var agentId = message.CurrentAgentId; @@ -43,6 +44,12 @@ public async Task Execute(RoleDialogModel message) } var conversationId = _stateService.GetConversationId(); + if (string.IsNullOrWhiteSpace(conversationId)) + { + message.Content = "System Error: Conversation context is unavailable for A2A session continuation."; + message.StopCompletion = true; + return false; + } try { @@ -54,6 +61,7 @@ public async Task Execute(RoleDialogModel message) ); message.Content = responseText; + message.StopCompletion = true; return true; } catch (Exception ex) diff --git a/src/Infrastructure/BotSharp.Core.A2A/Hooks/A2AAgentHook.cs b/src/Infrastructure/BotSharp.Core.A2A/Hooks/A2AAgentHook.cs index d3d913128..2cc3dbb4b 100644 --- a/src/Infrastructure/BotSharp.Core.A2A/Hooks/A2AAgentHook.cs +++ b/src/Infrastructure/BotSharp.Core.A2A/Hooks/A2AAgentHook.cs @@ -1,3 +1,4 @@ +using A2A; using BotSharp.Abstraction.Agents; using BotSharp.Abstraction.Agents.Enums; using BotSharp.Abstraction.Agents.Models; @@ -5,6 +6,7 @@ using BotSharp.Abstraction.Functions.Models; using BotSharp.Core.A2A.Services; using BotSharp.Core.A2A.Settings; +using Microsoft.Extensions.Logging; using System.Text.Json; namespace BotSharp.Core.A2A.Hooks; @@ -15,12 +17,14 @@ public class A2AAgentHook : AgentHookBase private readonly A2ASettings _a2aSettings; private readonly IA2AService _a2aService; + private readonly ILogger _logger; - public A2AAgentHook(IServiceProvider services, IA2AService a2aService, A2ASettings a2aSettings, AgentSettings agentSettings) + public A2AAgentHook(IServiceProvider services, IA2AService a2aService, A2ASettings a2aSettings, AgentSettings agentSettings, ILogger logger) : base(services, agentSettings) { _a2aService = a2aService; _a2aSettings = a2aSettings; + _logger = logger; } public override async Task OnAgentLoading(string id) @@ -45,7 +49,16 @@ public override async Task OnAgentLoaded(Agent agent) var remoteConfig = _a2aSettings.Agents?.FirstOrDefault(x => x.Id == agent.Id); if (remoteConfig != null) { - var agentCard = await _a2aService.GetCapabilitiesAsync(remoteConfig.Endpoint); + AgentCard? agentCard = null; + try + { + agentCard = await _a2aService.GetCapabilitiesAsync(remoteConfig.Endpoint); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to resolve A2A agent card for endpoint {AgentEndpoint}. Using configured metadata.", remoteConfig.Endpoint); + } + if (agentCard != null) { agent.Name = agentCard.Name; @@ -54,34 +67,34 @@ public override async Task OnAgentLoaded(Agent agent) $"Your ONLY goal is to forward the user's request verbatim to the external service. " + $"You must use the function 'delegate_to_a2a' to communicate with it. " + $"Do not attempt to answer the question yourself."; + } - var properties = new Dictionary + var properties = new Dictionary + { { + "user_query", + new { - "user_query", - new - { - type = "string", - description = "The exact user request or task description to be forwarded." - } + type = "string", + description = "The exact user request or task description to be forwarded." } - }; + } + }; - var propertiesJson = JsonSerializer.Serialize(properties); - var propertiesDocument = JsonDocument.Parse(propertiesJson); + var propertiesJson = JsonSerializer.Serialize(properties); + var propertiesDocument = JsonDocument.Parse(propertiesJson); - agent.Functions.Add(new FunctionDef + agent.Functions.Add(new FunctionDef + { + Name = "delegate_to_a2a", + Description = $"Delegates the task to the external {remoteConfig.Name} via A2A protocol.", + Parameters = new FunctionParametersDef() { - Name = "delegate_to_a2a", - Description = $"Delegates the task to the external {remoteConfig.Name} via A2A protocol.", - Parameters = new FunctionParametersDef() - { - Type = "object", - Properties = propertiesDocument, - Required = new List { "user_query" } - } - }); - } + Type = "object", + Properties = propertiesDocument, + Required = new List { "user_query" } + } + }); } await base.OnAgentLoaded(agent); } diff --git a/src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs b/src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs index 0dbe88b9e..46029495f 100644 --- a/src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs +++ b/src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs @@ -1,158 +1,242 @@ using A2A; +using BotSharp.Abstraction.Conversations; +using BotSharp.Abstraction.Conversations.Enums; +using BotSharp.Core.A2A.Settings; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; -using System.Net.ServerSentEvents; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; namespace BotSharp.Core.A2A.Services; + public class A2AService : IA2AService { + private const string ContinuationTokenStatePrefix = "a2a:continuation-token"; + + // Protocol binding name constants from the A2A v1 specification. + private const string BindingHttpJson = "http+json"; + private const string BindingJsonRpc = "json-rpc"; + private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; + private readonly IConversationStateService _conversationState; private readonly IServiceProvider _services; - - private readonly Dictionary _clientCache = new Dictionary(); - - public A2AService(IHttpClientFactory httpClientFactory, IServiceProvider services, ILogger logger) + private readonly A2ASettings _settings; + + // High-level A2A v1 agent cache + private readonly Dictionary _aiAgentCache = new(); +#pragma warning disable MEAI001 + private readonly Dictionary _continuationTokenCache = new(); +#pragma warning restore MEAI001 + + // LEGACY: Used for task APIs and the StreamResponse compatibility overload. + private readonly Dictionary _clientCache = new(); + + public A2AService( + IHttpClientFactory httpClientFactory, + IServiceProvider services, + ILogger logger, + A2ASettings settings, + IConversationStateService conversationState) { _httpClientFactory = httpClientFactory; _services = services; _logger = logger; - } + _settings = settings; + _conversationState = conversationState; + } public async Task GetCapabilitiesAsync(string agentEndpoint, CancellationToken cancellationToken = default) { - var resolver = new A2ACardResolver(new Uri(agentEndpoint)); - return await resolver.GetAgentCardAsync(); + var endpointUri = CreateValidatedEndpointUri(agentEndpoint, nameof(agentEndpoint)); + var resolver = new A2ACardResolver(endpointUri); + return await resolver.GetAgentCardAsync(cancellationToken); } - public async Task SendMessageAsync(string agentEndpoint, string text, string contextId, CancellationToken cancellationToken) + private async Task CreateAIAgentAsync(string agentEndpoint, CancellationToken cancellationToken = default) { + var endpointUri = CreateValidatedEndpointUri(agentEndpoint, nameof(agentEndpoint)); - if (!_clientCache.TryGetValue(agentEndpoint, out var client)) + if (_aiAgentCache.TryGetValue(agentEndpoint, out var cachedAgent)) { - HttpClient httpclient = _httpClientFactory.CreateClient(); + return cachedAgent; + } + + var resolver = new A2ACardResolver(endpointUri); + var aiAgent = await resolver.GetAIAgentAsync(); + _aiAgentCache[agentEndpoint] = aiAgent; + return aiAgent; + } - client = new A2AClient(new Uri(agentEndpoint), httpclient); - _clientCache[agentEndpoint] = client; + private Uri CreateValidatedEndpointUri(string endpoint, string paramName) + { + if (string.IsNullOrWhiteSpace(endpoint)) + { + _logger.LogWarning("A2A endpoint parameter {ParamName} is null or empty.", paramName); + throw new ArgumentException("A2A endpoint is required and cannot be null or empty.", paramName); } - var messagePayload = new AgentMessage + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || + (endpointUri.Scheme != Uri.UriSchemeHttp && endpointUri.Scheme != Uri.UriSchemeHttps)) { - Role = MessageRole.User, - ContextId = contextId, - Parts = new List + _logger.LogWarning("A2A endpoint parameter {ParamName} is invalid: {Endpoint}", paramName, endpoint); + throw new ArgumentException("A2A endpoint must be a valid absolute HTTP or HTTPS URI.", paramName); + } + + return endpointUri; + } + + private static string BuildSessionCacheKey(string agentEndpoint, string contextId) + => $"{agentEndpoint}::{contextId}"; + + private static string BuildContinuationTokenStateKey(string agentEndpoint, string contextId) + { + var endpointHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(agentEndpoint))); + return $"{ContinuationTokenStatePrefix}:{contextId}:{endpointHash}"; + } + +#pragma warning disable MEAI001 + private AgentRunOptions? GetRunOptions(string agentEndpoint, string contextId) + { + if (string.IsNullOrWhiteSpace(contextId)) + { + return null; + } + + var cacheKey = BuildSessionCacheKey(agentEndpoint, contextId); + if (!_continuationTokenCache.TryGetValue(cacheKey, out var continuationToken)) + { + continuationToken = ReadContinuationTokenFromConversationState(agentEndpoint, contextId); + if (continuationToken == null) { - new TextPart { Text = text } + return null; } - }; - var sendParams = new MessageSendParams + _continuationTokenCache[cacheKey] = continuationToken; + } + + return new AgentRunOptions { - Message = messagePayload + ContinuationToken = continuationToken }; + } - try + private void UpdateContinuationToken(string agentEndpoint, string contextId, ResponseContinuationToken? continuationToken) + { + if (string.IsNullOrWhiteSpace(contextId) || continuationToken == null) { - _logger.LogInformation($"Sending A2A message to {agentEndpoint}. ContextId: {contextId}"); - var responseBase = await client.SendMessageAsync(sendParams, cancellationToken); + return; + } - if (responseBase is AgentMessage responseMsg) - { - if (responseMsg.Parts != null && responseMsg.Parts.Any()) - { - var textPart = responseMsg.Parts.First() as TextPart; - return textPart?.Text ?? string.Empty; - } - } - else if( responseBase is AgentTask atask) - { - return $"Task created with ID: {atask.Id}, Status: {atask.Status}"; - } - else - { - return "Unexpected task type."; - } + var cacheKey = BuildSessionCacheKey(agentEndpoint, contextId); + _continuationTokenCache[cacheKey] = continuationToken; + PersistContinuationTokenToConversationState(agentEndpoint, contextId, continuationToken); + } - return string.Empty; + private ResponseContinuationToken? ReadContinuationTokenFromConversationState(string agentEndpoint, string contextId) + { + var stateKey = BuildContinuationTokenStateKey(agentEndpoint, contextId); + var serializedToken = _conversationState.GetState(stateKey); + if (string.IsNullOrWhiteSpace(serializedToken)) + { + return null; } - catch (HttpRequestException ex) + + try { - _logger.LogError(ex, $"Network error communicating with A2A agent at {agentEndpoint}"); - throw new Exception($"Remote agent unavailable: {ex.Message}"); + return JsonSerializer.Deserialize(serializedToken); } catch (Exception ex) { - _logger.LogError(ex, $"A2A Protocol error: {ex.Message}"); - throw; + _logger.LogWarning(ex, + "Failed to deserialize A2A continuation token for context {ContextId} and endpoint {AgentEndpoint}.", + contextId, + agentEndpoint); + _conversationState.RemoveState(stateKey); + return null; } } - public async Task SendMessageStreamingAsync(string endPoint, List parts, Func, Task>? onStreamingEventReceived, CancellationToken cancellationToken = default) + private void PersistContinuationTokenToConversationState(string agentEndpoint, string contextId, ResponseContinuationToken continuationToken) { - A2ACardResolver cardResolver = new(new Uri(endPoint)); - AgentCard agentCard = await cardResolver.GetAgentCardAsync(); - A2AClient client = new A2AClient(new Uri(agentCard.Url)); + var stateKey = BuildContinuationTokenStateKey(agentEndpoint, contextId); - AgentMessage userMessage = new() + try { - Role = MessageRole.User, - Parts = parts - }; - - await foreach (SseItem sseItem in client.SendMessageStreamingAsync(new MessageSendParams { Message = userMessage })) + var serializedToken = JsonSerializer.Serialize(continuationToken); + _conversationState.SetState( + stateKey, + serializedToken, + isNeedVersion: false, + source: StateSource.Application); + } + catch (Exception ex) { - await onStreamingEventReceived?.Invoke(sseItem); + _logger.LogWarning(ex, + "Failed to persist A2A continuation token for context {ContextId} and endpoint {AgentEndpoint}.", + contextId, + agentEndpoint); } - - Console.WriteLine(" Streaming completed."); } +#pragma warning restore MEAI001 - public async Task ListenForTaskEventAsync(string endPoint, string taskId, Func, ValueTask>? onTaskEventReceived = null, CancellationToken cancellationToken = default) + // HIGH-LEVEL: Preferred A2A v1 API for message sending + public async Task SendMessageAsync(string agentEndpoint, string text, string contextId, CancellationToken cancellationToken) { - - if (onTaskEventReceived == null) + try { - return; + var agent = await CreateAIAgentAsync(agentEndpoint, cancellationToken); + var runOptions = GetRunOptions(agentEndpoint, contextId); + _logger.LogInformation("Sending A2A message via AIAgent to {AgentEndpoint}. ContextId: {ContextId}", agentEndpoint, contextId); + var response = await agent.RunAsync( + message: text ?? string.Empty, + options: runOptions, + cancellationToken: cancellationToken); + +#pragma warning disable MEAI001 + UpdateContinuationToken(agentEndpoint, contextId, response.ContinuationToken); +#pragma warning restore MEAI001 + return response.Text ?? string.Empty; } - - A2ACardResolver cardResolver = new(new Uri(endPoint)); - AgentCard agentCard = await cardResolver.GetAgentCardAsync(); - A2AClient client = new A2AClient(new Uri(agentCard.Url)); - - await foreach (SseItem sseItem in client.SubscribeToTaskAsync(taskId)) + catch (HttpRequestException ex) { - await onTaskEventReceived.Invoke(sseItem); - Console.WriteLine(" Task event received: " + JsonSerializer.Serialize(sseItem.Data)); + _logger.LogError(ex, $"Network error communicating with A2A agent at {agentEndpoint}"); + throw new Exception($"Remote agent unavailable: {ex.Message}"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"A2A Protocol error: {ex.Message}"); + throw; } - } - public async Task SetPushNotifications(string endPoint, PushNotificationConfig config, CancellationToken cancellationToken = default) + // HIGH-LEVEL: Streaming uses AIAgent.RunStreamingAsync in A2A v1. + public async Task SendMessageStreamingAsync(string endPoint, List parts, Func? onStreamingEventReceived, CancellationToken cancellationToken = default) { - A2ACardResolver cardResolver = new(new Uri(endPoint)); - AgentCard agentCard = await cardResolver.GetAgentCardAsync(); - A2AClient client = new A2AClient(new Uri(agentCard.Url)); - await client.SetPushNotificationAsync(new TaskPushNotificationConfig() + var safeParts = parts?.ToList() ?? new List(); + + var userMessage = new Message { - PushNotificationConfig = config - }); - } + MessageId = Guid.NewGuid().ToString("N"), + Role = Role.User, + Parts = safeParts + }; - public async Task CancelTaskAsync(string endPoint, string taskId, CancellationToken cancellationToken = default) - { - A2ACardResolver cardResolver = new(new Uri(endPoint)); - AgentCard agentCard = await cardResolver.GetAgentCardAsync(); - A2AClient client = new A2AClient(new Uri(agentCard.Url)); - return await client.CancelTaskAsync(taskId); - } + var agent = await CreateAIAgentAsync(endPoint, cancellationToken); + var chatMessage = userMessage.ToChatMessage(); - public async Task GetTaskAsync(string endPoint, string taskId, CancellationToken cancellationToken = default) - { - A2ACardResolver cardResolver = new(new Uri(endPoint)); - AgentCard agentCard = await cardResolver.GetAgentCardAsync(); - A2AClient client = new A2AClient(new Uri(agentCard.Url)); - return await client.GetTaskAsync(taskId); - } + await foreach (var streamResponse in agent.RunStreamingAsync( + messages: new[] { chatMessage }, + options: null, + cancellationToken: cancellationToken)) + { + if (onStreamingEventReceived != null) + await onStreamingEventReceived(streamResponse); + } + _logger.LogInformation("Streaming completed."); + } } diff --git a/src/Infrastructure/BotSharp.Core.A2A/Services/IA2AService.cs b/src/Infrastructure/BotSharp.Core.A2A/Services/IA2AService.cs index 47f921c2e..6dcdb8b67 100644 --- a/src/Infrastructure/BotSharp.Core.A2A/Services/IA2AService.cs +++ b/src/Infrastructure/BotSharp.Core.A2A/Services/IA2AService.cs @@ -1,8 +1,8 @@ using A2A; +using Microsoft.Agents.AI; using System; using System.Collections.Generic; using System.Linq; -using System.Net.ServerSentEvents; using System.Text; using System.Threading.Tasks; @@ -14,13 +14,5 @@ public interface IA2AService Task GetCapabilitiesAsync(string agentEndpoint, CancellationToken cancellationToken = default); - Task SendMessageStreamingAsync(string endPoint, List parts, Func, Task>? onStreamingEventReceived,CancellationToken cancellationToken = default); - - Task ListenForTaskEventAsync(string endPoint, string taskId, Func, ValueTask>? onTaskEventReceived = null, CancellationToken cancellationToken = default); - - Task SetPushNotifications(string endPoint, PushNotificationConfig config, CancellationToken cancellationToken = default); - - Task CancelTaskAsync(string endPoint, string taskId, CancellationToken cancellationToken = default); - - Task GetTaskAsync(string endPoint, string taskId, CancellationToken cancellationToken); -} + Task SendMessageStreamingAsync(string endPoint, List parts, Func? onStreamingEventReceived, CancellationToken cancellationToken = default); + } diff --git a/src/Infrastructure/BotSharp.Core.A2A/Settings/A2ASettings.cs b/src/Infrastructure/BotSharp.Core.A2A/Settings/A2ASettings.cs index f59d3cf35..5f1259ec7 100644 --- a/src/Infrastructure/BotSharp.Core.A2A/Settings/A2ASettings.cs +++ b/src/Infrastructure/BotSharp.Core.A2A/Settings/A2ASettings.cs @@ -13,5 +13,5 @@ public class RemoteAgentConfig public string Name { get; set; } public string Description { get; set; } public string Endpoint { get; set; } - public List Capabilities { get; set; } + public List Capabilities { get; set; } } diff --git a/tests/BotSharp.Plugin.PizzaBot/data/agents/cdd9023f-a371-407a-43bf-f36ddccce340/agent.json b/tests/BotSharp.Plugin.PizzaBot/data/agents/cdd9023f-a371-407a-43bf-f36ddccce340/agent.json index ce963da82..d7111bacf 100644 --- a/tests/BotSharp.Plugin.PizzaBot/data/agents/cdd9023f-a371-407a-43bf-f36ddccce340/agent.json +++ b/tests/BotSharp.Plugin.PizzaBot/data/agents/cdd9023f-a371-407a-43bf-f36ddccce340/agent.json @@ -1,14 +1,14 @@ { "id": "cdd9023f-a371-407a-43bf-f36ddccce340", - "name": "SportKiosk", - "description": "Answers questions about sport events", + "name": "OpenClaw", + "description": "Microsoft Agent Framework orchestration backend for OpenClaw", "type": "a2a-remote", "disabled": false, "isPublic": true, "profiles": [ "pizza" ], "labels": [ "experiment" ], "llmConfig": { - "provider": "openai", - "model": "gpt-5-nano" + "provider": "azure-openai", + "model": "gpt-4.1" } } \ No newline at end of file diff --git a/tests/UnitTest/A2AServiceContinuationTokenTests.cs b/tests/UnitTest/A2AServiceContinuationTokenTests.cs new file mode 100644 index 000000000..c47016565 --- /dev/null +++ b/tests/UnitTest/A2AServiceContinuationTokenTests.cs @@ -0,0 +1,237 @@ +using BotSharp.Abstraction.Conversations; +using BotSharp.Abstraction.Conversations.Enums; +using BotSharp.Abstraction.Conversations.Models; +using BotSharp.Core.A2A.Services; +using BotSharp.Core.A2A.Settings; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace UnitTest; + +[TestClass] +public class A2AServiceContinuationTokenTests +{ + private const string Endpoint = "https://remote-agent.example.com"; + private const string ConversationId = "conv-001"; + + [TestMethod] + public void ContinuationToken_ShouldPersistToConversationState_AfterUpdate() + { +#pragma warning disable MEAI001 + var state = new InMemoryConversationStateService(); + var service = CreateService(state); + var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); + + InvokePrivate( + service, + "UpdateContinuationToken", + Endpoint, + ConversationId, + continuationToken); + + var stateKey = BuildExpectedStateKey(Endpoint, ConversationId); + var persisted = state.GetState(stateKey); + + Assert.IsFalse(string.IsNullOrWhiteSpace(persisted)); + Assert.IsTrue(state.ContainsState(stateKey)); +#pragma warning restore MEAI001 + } + + [TestMethod] + public void ContinuationToken_ShouldRestoreFromState_InNewServiceScope() + { +#pragma warning disable MEAI001 + var sharedState = new InMemoryConversationStateService(); + var serviceFromScope1 = CreateService(sharedState); + var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); + + InvokePrivate( + serviceFromScope1, + "UpdateContinuationToken", + Endpoint, + ConversationId, + continuationToken); + + var serviceFromScope2 = CreateService(sharedState); + var options = InvokePrivate( + serviceFromScope2, + "GetRunOptions", + Endpoint, + ConversationId); + + Assert.IsNotNull(options); + Assert.IsNotNull(options.ContinuationToken); +#pragma warning restore MEAI001 + } + + [TestMethod] + public void CorruptedTokenState_ShouldBeIgnored_AndRemoved() + { +#pragma warning disable MEAI001 + var state = new InMemoryConversationStateService(); + var logger = new TestLogger(); + var service = CreateService(state, logger); + var stateKey = BuildExpectedStateKey(Endpoint, ConversationId); + + state.SetState( + stateKey, + "this-is-not-json", + isNeedVersion: false, + source: StateSource.Application); + + var options = InvokePrivate( + service, + "GetRunOptions", + Endpoint, + ConversationId); + + Assert.IsNull(options); + Assert.IsFalse(state.ContainsState(stateKey)); + Assert.IsTrue(logger.Logs.Any(x => x.Level == LogLevel.Warning)); +#pragma warning restore MEAI001 + } + + private static A2AService CreateService( + IConversationStateService state, + ILogger? logger = null) + { + return new A2AService( + httpClientFactory: new DummyHttpClientFactory(), + services: new DummyServiceProvider(), + logger: logger ?? new TestLogger(), + settings: new A2ASettings(), + conversationState: state); + } + + private static string BuildExpectedStateKey(string agentEndpoint, string conversationId) + { + var endpointHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(agentEndpoint))); + return $"a2a:continuation-token:{conversationId}:{endpointHash}"; + } + + private static void InvokePrivate(object instance, string methodName, params object?[] args) + { + var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(method, $"Private method '{methodName}' was not found."); + method!.Invoke(instance, args); + } + + private static T InvokePrivate(object instance, string methodName, params object?[] args) + { + var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(method, $"Private method '{methodName}' was not found."); + return (T)method!.Invoke(instance, args)!; + } + + private sealed class DummyHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } + + private sealed class DummyServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private sealed class InMemoryConversationStateService : IConversationStateService + { + private readonly Dictionary _states = new(); + + public string GetConversationId() => ConversationId; + + public Task> Load(string conversationId, bool isReadOnly = false) + => Task.FromResult(new Dictionary(_states)); + + public string GetState(string name, string defaultValue = "") + => _states.TryGetValue(name, out var value) ? value : defaultValue; + + public bool ContainsState(string name) => _states.ContainsKey(name); + + public Dictionary GetStates() => new(_states); + + public IConversationStateService SetState( + string name, + T value, + bool isNeedVersion = true, + int activeRounds = -1, + string valueType = StateDataType.String, + string source = StateSource.User, + bool readOnly = false) + { + if (value != null) + { + _states[name] = value.ToString() ?? string.Empty; + } + + return this; + } + + public void SaveStateByArgs(JsonDocument args) + { + } + + public bool RemoveState(string name) => _states.Remove(name); + + public void CleanStates(params string[] excludedStates) + { + var keep = new HashSet(excludedStates ?? Array.Empty()); + var keysToDelete = _states.Keys.Where(k => !keep.Contains(k)).ToList(); + foreach (var key in keysToDelete) + { + _states.Remove(key); + } + } + + public Task Save() => Task.CompletedTask; + + public ConversationState GetCurrentState() => new(); + + public void SetCurrentState(ConversationState state) + { + } + + public void ResetCurrentState() + { + } + + public void Dispose() + { + } + } + + private sealed class TestLogger : ILogger + { + public List Logs { get; } = new(); + + public IDisposable BeginScope(TState state) where TState : notnull + => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Logs.Add(new LogEntry(logLevel, formatter(state, exception), exception)); + } + + public sealed record LogEntry(LogLevel Level, string Message, Exception? Exception); + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } + } +} diff --git a/tests/UnitTest/UnitTest.csproj b/tests/UnitTest/UnitTest.csproj index 401b2e5e9..deb1a54d3 100644 --- a/tests/UnitTest/UnitTest.csproj +++ b/tests/UnitTest/UnitTest.csproj @@ -13,8 +13,6 @@ - - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -22,5 +20,6 @@ +