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 @@
+