diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ICommandHandler.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ICommandHandler.cs
index 223c60e3ba..52623e5d65 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ICommandHandler.cs
+++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ICommandHandler.cs
@@ -24,5 +24,5 @@ public interface ICommandHandler
/// The raw user input string.
/// The current agent session.
/// if this handler handled the input; otherwise.
- bool TryHandle(string input, AgentSession session);
+ ValueTask TryHandleAsync(string input, AgentSession session);
}
diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs
index c3d112ce11..e95c1a2010 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs
+++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs
@@ -27,17 +27,17 @@ public ModeCommandHandler(AgentModeProvider? modeProvider, IReadOnlyDictionary this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
///
- public bool TryHandle(string input, AgentSession session)
+ public ValueTask TryHandleAsync(string input, AgentSession session)
{
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
{
- return false;
+ return ValueTask.FromResult(false);
}
if (this._modeProvider is null)
{
System.Console.WriteLine("AgentModeProvider is not available.");
- return true;
+ return ValueTask.FromResult(true);
}
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
@@ -45,7 +45,7 @@ public bool TryHandle(string input, AgentSession session)
{
string current = this._modeProvider.GetMode(session);
System.Console.WriteLine($"\n Current mode: {current}\n");
- return true;
+ return ValueTask.FromResult(true);
}
string newMode = parts[1];
@@ -64,6 +64,6 @@ public bool TryHandle(string input, AgentSession session)
System.Console.ResetColor();
}
- return true;
+ return ValueTask.FromResult(true);
}
}
diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs
index 6cc52e56bf..36068c2347 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs
+++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs
@@ -24,7 +24,7 @@ public TodoCommandHandler(TodoProvider? todoProvider)
public string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
///
- public bool TryHandle(string input, AgentSession session)
+ public async ValueTask TryHandleAsync(string input, AgentSession session)
{
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
{
@@ -37,7 +37,7 @@ public bool TryHandle(string input, AgentSession session)
return true;
}
- var todos = this._todoProvider.GetAllTodos(session);
+ var todos = await this._todoProvider.GetAllTodosAsync(session).ConfigureAwait(false);
if (todos.Count == 0)
{
System.Console.WriteLine("\n No todos yet.\n");
diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs
index a5ebaaf918..cd9d345fcf 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs
+++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs
@@ -69,7 +69,7 @@ public static async Task RunAgentAsync(AIAgent agent, string title, string userP
bool handled = false;
foreach (var handler in commandHandlers)
{
- if (handler.TryHandle(userInput, session))
+ if (await handler.TryHandleAsync(userInput, session).ConfigureAwait(false))
{
handled = true;
break;
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs
index 3a26a07f0b..f5f222e28b 100644
--- a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs
@@ -1,8 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -30,9 +33,13 @@ namespace Microsoft.Agents.AI;
/// - TodoList_GetAll — Retrieve all todo items (complete and incomplete).
///
///
+///
+/// All operations are thread-safe; concurrent reads and mutations on the same session are serialized
+/// using a per-session lock to prevent duplicate IDs, lost updates, or inconsistent reads.
+///
///
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
-public sealed class TodoProvider : AIContextProvider
+public sealed class TodoProvider : AIContextProvider, IDisposable
{
private const string DefaultInstructions =
"""
@@ -55,6 +62,10 @@ Ask questions from the user where clarification is needed to create effective to
private readonly ProviderSessionState _sessionState;
private readonly string _instructions;
+ private readonly bool _suppressTodoListMessage;
+ private readonly Func, string>? _todoListMessageBuilder;
+ private readonly ConditionalWeakTable _sessionLocks = new();
+ private readonly SemaphoreSlim _nullSessionLock = new(1, 1);
private IReadOnlyList? _stateKeys;
///
@@ -64,6 +75,8 @@ Ask questions from the user where clarification is needed to create effective to
public TodoProvider(TodoProviderOptions? options = null)
{
this._instructions = options?.Instructions ?? DefaultInstructions;
+ this._suppressTodoListMessage = options?.SuppressTodoListMessage ?? false;
+ this._todoListMessageBuilder = options?.TodoListMessageBuilder;
this._sessionState = new ProviderSessionState(
_ => new TodoState(),
this.GetType().Name,
@@ -73,64 +86,146 @@ public TodoProvider(TodoProviderOptions? options = null)
///
public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
+ ///
+ public void Dispose()
+ {
+ this._nullSessionLock.Dispose();
+ }
+
///
/// Gets all todo items from the session state.
///
+ ///
+ /// The returned instances are the live objects from internal state.
+ /// Modifying their properties will mutate the provider's state directly.
+ ///
/// The agent session to read todos from.
- /// A read-only list of all todo items.
- public IReadOnlyList GetAllTodos(AgentSession? session)
+ /// A list of all todo items. The items are live references to internal state.
+ public async Task> GetAllTodosAsync(AgentSession? session)
{
- return this._sessionState.GetOrInitializeState(session).Items;
+ SemaphoreSlim sessionLock = this.GetSessionLock(session);
+ await sessionLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ TodoState state = this._sessionState.GetOrInitializeState(session);
+ return state.Items.ToList();
+ }
+ finally
+ {
+ sessionLock.Release();
+ }
}
///
/// Gets the remaining (incomplete) todo items from the session state.
///
+ ///
+ /// The returned instances are the live objects from internal state.
+ /// Modifying their properties will mutate the provider's state directly.
+ ///
/// The agent session to read todos from.
- /// A list of incomplete todo items.
- public List GetRemainingTodos(AgentSession? session)
+ /// A list of incomplete todo items. The items are live references to internal state.
+ public async Task> GetRemainingTodosAsync(AgentSession? session)
{
- return this._sessionState.GetOrInitializeState(session).Items.Where(t => !t.IsComplete).ToList();
+ SemaphoreSlim sessionLock = this.GetSessionLock(session);
+ await sessionLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ TodoState state = this._sessionState.GetOrInitializeState(session);
+ return state.Items.Where(t => !t.IsComplete).ToList();
+ }
+ finally
+ {
+ sessionLock.Release();
+ }
}
///
- protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
- TodoState state = this._sessionState.GetOrInitializeState(context.Session);
-
- return new ValueTask(new AIContext
+ var aiContext = new AIContext
{
Instructions = this._instructions,
- Tools = this.CreateTools(state, context.Session),
- });
+ Tools = this.CreateTools(context.Session),
+ };
+
+ if (!this._suppressTodoListMessage)
+ {
+ // Inject a synthetic user message summarizing the current todo list so the agent
+ // is aware of outstanding work at the start of each invocation.
+ SemaphoreSlim sessionLock = this.GetSessionLock(context.Session);
+ await sessionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ List currentItems;
+ try
+ {
+ TodoState state = this._sessionState.GetOrInitializeState(context.Session);
+ currentItems = state.Items.ToList();
+ }
+ finally
+ {
+ sessionLock.Release();
+ }
+
+ string message = this._todoListMessageBuilder is not null
+ ? this._todoListMessageBuilder(currentItems)
+ : FormatTodoListMessage(currentItems);
+
+ aiContext.Messages =
+ [
+ new ChatMessage(ChatRole.User, message),
+ ];
+ }
+
+ return aiContext;
}
- // Note: These tool delegates mutate shared session state without synchronization.
- // This is safe because FunctionInvokingChatClient serializes tool calls within a single run.
- private AITool[] CreateTools(TodoState state, AgentSession? session)
+ ///
+ /// Returns the per-session semaphore used to serialize all todo operations.
+ ///
+ private SemaphoreSlim GetSessionLock(AgentSession? session)
+ {
+ if (session is null)
+ {
+ return this._nullSessionLock;
+ }
+
+ return this._sessionLocks.GetValue(session, _ => new SemaphoreSlim(1, 1));
+ }
+
+ private AITool[] CreateTools(AgentSession? session)
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
return
[
AIFunctionFactory.Create(
- (List todos) =>
+ async (List todos) =>
{
- var created = new List();
- foreach (var input in todos)
+ SemaphoreSlim sessionLock = this.GetSessionLock(session);
+ await sessionLock.WaitAsync().ConfigureAwait(false);
+ try
{
- var item = new TodoItem
+ TodoState state = this._sessionState.GetOrInitializeState(session);
+ var created = new List();
+ foreach (var input in todos)
{
- Id = state.NextId++,
- Title = input.Title,
- Description = input.Description,
- };
- state.Items.Add(item);
- created.Add(item);
- }
+ var item = new TodoItem
+ {
+ Id = state.NextId++,
+ Title = input.Title.Trim(),
+ Description = input.Description?.Trim(),
+ };
+ state.Items.Add(item);
+ created.Add(item);
+ }
- this._sessionState.SaveState(session, state);
- return created;
+ this._sessionState.SaveState(session, state);
+ return created;
+ }
+ finally
+ {
+ sessionLock.Release();
+ }
},
new AIFunctionFactoryOptions
{
@@ -140,25 +235,35 @@ private AITool[] CreateTools(TodoState state, AgentSession? session)
}),
AIFunctionFactory.Create(
- (List ids) =>
+ async (List ids) =>
{
- var idSet = new HashSet(ids);
- int completed = 0;
- foreach (TodoItem item in state.Items)
+ SemaphoreSlim sessionLock = this.GetSessionLock(session);
+ await sessionLock.WaitAsync().ConfigureAwait(false);
+ try
{
- if (!item.IsComplete && idSet.Contains(item.Id))
+ TodoState state = this._sessionState.GetOrInitializeState(session);
+ var idSet = new HashSet(ids);
+ int completed = 0;
+ foreach (TodoItem item in state.Items)
{
- item.IsComplete = true;
- completed++;
+ if (!item.IsComplete && idSet.Contains(item.Id))
+ {
+ item.IsComplete = true;
+ completed++;
+ }
}
- }
- if (completed > 0)
+ if (completed > 0)
+ {
+ this._sessionState.SaveState(session, state);
+ }
+
+ return completed;
+ }
+ finally
{
- this._sessionState.SaveState(session, state);
+ sessionLock.Release();
}
-
- return completed;
},
new AIFunctionFactoryOptions
{
@@ -168,17 +273,27 @@ private AITool[] CreateTools(TodoState state, AgentSession? session)
}),
AIFunctionFactory.Create(
- (List ids) =>
+ async (List ids) =>
{
- var idSet = new HashSet(ids);
- int removed = state.Items.RemoveAll(t => idSet.Contains(t.Id));
+ SemaphoreSlim sessionLock = this.GetSessionLock(session);
+ await sessionLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ TodoState state = this._sessionState.GetOrInitializeState(session);
+ var idSet = new HashSet(ids);
+ int removed = state.Items.RemoveAll(t => idSet.Contains(t.Id));
+
+ if (removed > 0)
+ {
+ this._sessionState.SaveState(session, state);
+ }
- if (removed > 0)
+ return removed;
+ }
+ finally
{
- this._sessionState.SaveState(session, state);
+ sessionLock.Release();
}
-
- return removed;
},
new AIFunctionFactoryOptions
{
@@ -188,7 +303,20 @@ private AITool[] CreateTools(TodoState state, AgentSession? session)
}),
AIFunctionFactory.Create(
- () => state.Items.Where(t => !t.IsComplete).ToList(),
+ async () =>
+ {
+ SemaphoreSlim sessionLock = this.GetSessionLock(session);
+ await sessionLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ TodoState state = this._sessionState.GetOrInitializeState(session);
+ return state.Items.Where(t => !t.IsComplete).ToList();
+ }
+ finally
+ {
+ sessionLock.Release();
+ }
+ },
new AIFunctionFactoryOptions
{
Name = "TodoList_GetRemaining",
@@ -197,7 +325,20 @@ private AITool[] CreateTools(TodoState state, AgentSession? session)
}),
AIFunctionFactory.Create(
- () => state.Items,
+ async () =>
+ {
+ SemaphoreSlim sessionLock = this.GetSessionLock(session);
+ await sessionLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ TodoState state = this._sessionState.GetOrInitializeState(session);
+ return state.Items.ToList();
+ }
+ finally
+ {
+ sessionLock.Release();
+ }
+ },
new AIFunctionFactoryOptions
{
Name = "TodoList_GetAll",
@@ -206,4 +347,27 @@ private AITool[] CreateTools(TodoState state, AgentSession? session)
}),
];
}
+
+ internal static string FormatTodoListMessage(List items)
+ {
+ if (items.Count == 0)
+ {
+ return "### Current todo list\n- none yet";
+ }
+
+ var sb = new StringBuilder("### Current todo list\n");
+ foreach (var item in items)
+ {
+ string status = item.IsComplete ? "done" : "open";
+ sb.Append($"- {item.Id} [{status}] {item.Title}");
+ if (!string.IsNullOrWhiteSpace(item.Description))
+ {
+ sb.Append($": {item.Description}");
+ }
+
+ sb.AppendLine();
+ }
+
+ return sb.ToString().TrimEnd();
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProviderOptions.cs
index e451ff67d9..2cb331a7ae 100644
--- a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProviderOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProviderOptions.cs
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
@@ -19,4 +21,24 @@ public sealed class TodoProviderOptions
/// that guide the agent on how to manage todos effectively.
///
public string? Instructions { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to suppress injecting the todo list message
+ /// into the conversation context.
+ ///
+ ///
+ /// When (the default), a synthetic user message summarizing the current
+ /// todo list is injected at each invocation. When , no message is injected.
+ ///
+ public bool SuppressTodoListMessage { get; set; }
+
+ ///
+ /// Gets or sets a custom function that builds the todo list message text.
+ ///
+ ///
+ /// When (the default), the provider generates a standard formatted list
+ /// of todo items. When set, this function receives the current list of todo items and should
+ /// return a formatted string to inject as a user message.
+ ///
+ public Func, string>? TodoListMessageBuilder { get; set; }
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs
index 984a2ca1bc..d2724478d3 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs
@@ -328,7 +328,7 @@ public async Task State_PersistsInSessionStateBagAsync()
#region Public Helper Method Tests
///
- /// Verify that GetAllTodos returns all items after adding via tools.
+ /// Verify that GetAllTodosAsync returns all items after adding via tools.
///
[Fact]
public async Task PublicGetAllTodos_ReturnsAllItemsAsync()
@@ -348,7 +348,7 @@ await addTodos.InvokeAsync(new AIFunctionArguments()
});
// Act
- var todos = provider.GetAllTodos(session);
+ var todos = await provider.GetAllTodosAsync(session);
// Assert
Assert.Equal(2, todos.Count);
@@ -357,7 +357,7 @@ await addTodos.InvokeAsync(new AIFunctionArguments()
}
///
- /// Verify that GetRemainingTodos returns only incomplete items.
+ /// Verify that GetRemainingTodosAsync returns only incomplete items.
///
[Fact]
public async Task PublicGetRemainingTodos_ReturnsOnlyIncompleteAsync()
@@ -379,7 +379,7 @@ await addTodos.InvokeAsync(new AIFunctionArguments()
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } });
// Act
- var remaining = provider.GetRemainingTodos(session);
+ var remaining = await provider.GetRemainingTodosAsync(session);
// Assert
Assert.Single(remaining);
@@ -387,17 +387,17 @@ await addTodos.InvokeAsync(new AIFunctionArguments()
}
///
- /// Verify that GetAllTodos returns empty list for a new session.
+ /// Verify that GetAllTodosAsync returns empty list for a new session.
///
[Fact]
- public void PublicGetAllTodos_ReturnsEmptyForNewSession()
+ public async Task PublicGetAllTodos_ReturnsEmptyForNewSessionAsync()
{
// Arrange
var provider = new TodoProvider();
var session = new ChatClientAgentSession();
// Act
- var todos = provider.GetAllTodos(session);
+ var todos = await provider.GetAllTodosAsync(session);
// Assert
Assert.Empty(todos);
@@ -489,4 +489,293 @@ public async Task Options_Null_UsesDefaultInstructionsAsync()
}
#endregion
+
+ #region Message Injection Tests
+
+ ///
+ /// Verify that ProvideAIContextAsync injects a "none yet" message when the list is empty.
+ ///
+ [Fact]
+ public async Task ProvideAIContextAsync_InjectsEmptyTodoMessageAsync()
+ {
+ // Arrange
+ var provider = new TodoProvider();
+ var agent = new Mock().Object;
+ var session = new ChatClientAgentSession();
+#pragma warning disable MAAI001
+ var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
+#pragma warning restore MAAI001
+
+ // Act
+ AIContext result = await provider.InvokingAsync(context);
+
+ // Assert
+ Assert.NotNull(result.Messages);
+ var messages = result.Messages!.ToList();
+ Assert.Single(messages);
+ Assert.Contains("none yet", messages[0].Text);
+ Assert.Contains("### Current todo list", messages[0].Text);
+ }
+
+ ///
+ /// Verify that ProvideAIContextAsync injects a message listing existing todos with status.
+ ///
+ [Fact]
+ public async Task ProvideAIContextAsync_InjectsTodoListMessageAsync()
+ {
+ // Arrange
+ var provider = new TodoProvider();
+ var agent = new Mock().Object;
+ var session = new ChatClientAgentSession();
+#pragma warning disable MAAI001
+ var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
+#pragma warning restore MAAI001
+
+ // First invocation — add some todos (one with a description to cover that branch)
+ AIContext result1 = await provider.InvokingAsync(context);
+ AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
+ AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Complete");
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List
+ {
+ new() { Title = "First" },
+ new() { Title = "Second", Description = "Has details" },
+ },
+ });
+ await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } });
+
+ // Act — second invocation should see the updated list in messages
+ AIContext result2 = await provider.InvokingAsync(context);
+
+ // Assert
+ Assert.NotNull(result2.Messages);
+ var messages = result2.Messages!.ToList();
+ Assert.Single(messages);
+ string text = messages[0].Text!;
+ Assert.Contains("### Current todo list", text);
+ Assert.Contains("[done] First", text);
+ Assert.Contains("[open] Second", text);
+ Assert.Contains(": Has details", text);
+ }
+
+ ///
+ /// Verify that when SuppressTodoListMessage is true, no message is injected.
+ ///
+ [Fact]
+ public async Task ProvideAIContextAsync_SuppressTodoListMessage_NoMessageInjectedAsync()
+ {
+ // Arrange
+ var provider = new TodoProvider(new TodoProviderOptions { SuppressTodoListMessage = true });
+ var agent = new Mock().Object;
+ var session = new ChatClientAgentSession();
+#pragma warning disable MAAI001
+ var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
+#pragma warning restore MAAI001
+
+ // Act
+ AIContext result = await provider.InvokingAsync(context);
+
+ // Assert
+ Assert.Null(result.Messages);
+ }
+
+ ///
+ /// Verify that a custom TodoListMessageBuilder is used when provided.
+ ///
+ [Fact]
+ public async Task ProvideAIContextAsync_CustomTodoListMessageBuilder_UsesCustomFormatterAsync()
+ {
+ // Arrange
+ var provider = new TodoProvider(new TodoProviderOptions
+ {
+ TodoListMessageBuilder = items => $"Custom: {items.Count} items",
+ });
+ var agent = new Mock().Object;
+ var session = new ChatClientAgentSession();
+#pragma warning disable MAAI001
+ var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
+#pragma warning restore MAAI001
+
+ // First invocation — add a todo
+ AIContext result1 = await provider.InvokingAsync(context);
+ AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = "Task A" } },
+ });
+
+ // Act — second invocation should use the custom builder
+ AIContext result2 = await provider.InvokingAsync(context);
+
+ // Assert
+ Assert.NotNull(result2.Messages);
+ var messages = result2.Messages!.ToList();
+ Assert.Single(messages);
+ Assert.Equal("Custom: 1 items", messages[0].Text);
+ }
+
+ ///
+ /// Verify that SuppressTodoListMessage takes precedence over a set TodoListMessageBuilder.
+ ///
+ [Fact]
+ public async Task ProvideAIContextAsync_SuppressWinsOverBuilder_NoMessageInjectedAsync()
+ {
+ // Arrange
+ var provider = new TodoProvider(new TodoProviderOptions
+ {
+ SuppressTodoListMessage = true,
+ TodoListMessageBuilder = items => "Should not appear",
+ });
+ var agent = new Mock().Object;
+ var session = new ChatClientAgentSession();
+#pragma warning disable MAAI001
+ var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
+#pragma warning restore MAAI001
+
+ // Act
+ AIContext result = await provider.InvokingAsync(context);
+
+ // Assert
+ Assert.Null(result.Messages);
+ }
+
+ ///
+ /// Verify that the list passed to TodoListMessageBuilder is a snapshot and mutating it does not affect state.
+ ///
+ [Fact]
+ public async Task ProvideAIContextAsync_BuilderReceivesSnapshot_MutationDoesNotAffectStateAsync()
+ {
+ // Arrange
+ IReadOnlyList? capturedList = null;
+ var provider = new TodoProvider(new TodoProviderOptions
+ {
+ TodoListMessageBuilder = items =>
+ {
+ capturedList = items;
+ return "snapshot test";
+ },
+ });
+ var agent = new Mock().Object;
+ var session = new ChatClientAgentSession();
+#pragma warning disable MAAI001
+ var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
+#pragma warning restore MAAI001
+
+ // Add a todo
+ AIContext result1 = await provider.InvokingAsync(context);
+ AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = "Original" } },
+ });
+
+ // Act — invoke again to trigger builder with 1 item
+ await provider.InvokingAsync(context);
+
+ // Mutate the captured snapshot
+ Assert.NotNull(capturedList);
+ var mutableList = (List)capturedList!;
+ mutableList.Clear();
+
+ // Assert — provider state is unaffected
+ var allTodos = await provider.GetAllTodosAsync(session);
+ Assert.Single(allTodos);
+ Assert.Equal("Original", allTodos[0].Title);
+ }
+
+ #endregion
+
+ #region Concurrency Tests
+
+ ///
+ /// Verify that concurrent add operations do not produce duplicate IDs.
+ ///
+ [Fact]
+ public async Task ConcurrentAdds_ProduceUniqueIdsAsync()
+ {
+ // Arrange
+ var provider = new TodoProvider();
+ var agent = new Mock().Object;
+ var session = new ChatClientAgentSession();
+#pragma warning disable MAAI001
+ var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
+#pragma warning restore MAAI001
+ AIContext result = await provider.InvokingAsync(context);
+ AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
+ AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
+
+ // Act — launch multiple concurrent adds
+ var tasks = Enumerable.Range(0, 10).Select(i =>
+ addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = $"Item {i}" } },
+ }).AsTask());
+ await Task.WhenAll(tasks);
+
+ // Assert — all IDs are unique and sequential
+ object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
+ var all = GetArrayResult(allResult);
+ Assert.Equal(10, all.Count);
+#pragma warning disable RCS1077 // Optimize LINQ method call — .Order() not available on net472
+ var ids = all.Select(e => e.GetProperty("id").GetInt32()).OrderBy(x => x).ToList();
+#pragma warning restore RCS1077
+ Assert.Equal(Enumerable.Range(1, 10).ToList(), ids);
+ }
+
+ ///
+ /// Verify that concurrent add and complete operations serialize correctly.
+ ///
+ [Fact]
+ public async Task ConcurrentAddAndComplete_SerializesCorrectlyAsync()
+ {
+ // Arrange
+ var provider = new TodoProvider();
+ var agent = new Mock().Object;
+ var session = new ChatClientAgentSession();
+#pragma warning disable MAAI001
+ var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
+#pragma warning restore MAAI001
+ AIContext result = await provider.InvokingAsync(context);
+ AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
+ AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete");
+ AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
+
+ // Add initial items
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List
+ {
+ new() { Title = "Existing 1" },
+ new() { Title = "Existing 2" },
+ new() { Title = "Existing 3" },
+ },
+ });
+
+ // Act — concurrent adds and completions
+ await Task.WhenAll(
+ addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = "New A" }, new() { Title = "New B" } },
+ }).AsTask(),
+ addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = "New C" } },
+ }).AsTask(),
+ completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1, 2, 3 } }).AsTask());
+
+ // Assert
+ object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
+ var all = GetArrayResult(allResult);
+ Assert.Equal(6, all.Count);
+#pragma warning disable RCS1077 // Optimize LINQ method call — .Order() not available on net472
+ var ids = all.Select(e => e.GetProperty("id").GetInt32()).OrderBy(x => x).ToList();
+#pragma warning restore RCS1077
+ Assert.Equal(ids.Count, ids.Distinct().Count()); // no duplicates
+ Assert.Equal(Enumerable.Range(1, 6).ToList(), ids);
+ var completedIds = all.Where(e => e.GetProperty("isComplete").GetBoolean()).Select(e => e.GetProperty("id").GetInt32()).ToHashSet();
+ Assert.Subset(new HashSet { 1, 2, 3 }, completedIds);
+ }
+
+ #endregion
}