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 }