.NET: Improve Todo multithreading and inject todos into message list (#5655)

* Improve Todo multithreading and inject todos into message list

* Address PR comments
This commit is contained in:
westey
2026-05-05 16:21:51 +01:00
committed by GitHub
Unverified
parent 384e26abd7
commit e9a6d43237
7 changed files with 542 additions and 67 deletions
@@ -24,5 +24,5 @@ public interface ICommandHandler
/// <param name="input">The raw user input string.</param>
/// <param name="session">The current agent session.</param>
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
bool TryHandle(string input, AgentSession session);
ValueTask<bool> TryHandleAsync(string input, AgentSession session);
}
@@ -27,17 +27,17 @@ internal sealed class ModeCommandHandler : ICommandHandler
public string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
/// <inheritdoc/>
public bool TryHandle(string input, AgentSession session)
public ValueTask<bool> 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 @@ internal sealed class ModeCommandHandler : ICommandHandler
{
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 @@ internal sealed class ModeCommandHandler : ICommandHandler
System.Console.ResetColor();
}
return true;
return ValueTask.FromResult(true);
}
}
@@ -24,7 +24,7 @@ internal sealed class TodoCommandHandler : ICommandHandler
public string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
/// <inheritdoc/>
public bool TryHandle(string input, AgentSession session)
public async ValueTask<bool> TryHandleAsync(string input, AgentSession session)
{
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
{
@@ -37,7 +37,7 @@ internal sealed class TodoCommandHandler : ICommandHandler
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");
@@ -69,7 +69,7 @@ public static class HarnessConsole
bool handled = false;
foreach (var handler in commandHandlers)
{
if (handler.TryHandle(userInput, session))
if (await handler.TryHandleAsync(userInput, session).ConfigureAwait(false))
{
handled = true;
break;
@@ -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;
/// <item><description><c>TodoList_GetAll</c> — Retrieve all todo items (complete and incomplete).</description></item>
/// </list>
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class TodoProvider : AIContextProvider
public sealed class TodoProvider : AIContextProvider, IDisposable
{
private const string DefaultInstructions =
"""
@@ -55,6 +62,10 @@ public sealed class TodoProvider : AIContextProvider
private readonly ProviderSessionState<TodoState> _sessionState;
private readonly string _instructions;
private readonly bool _suppressTodoListMessage;
private readonly Func<IReadOnlyList<TodoItem>, string>? _todoListMessageBuilder;
private readonly ConditionalWeakTable<AgentSession, SemaphoreSlim> _sessionLocks = new();
private readonly SemaphoreSlim _nullSessionLock = new(1, 1);
private IReadOnlyList<string>? _stateKeys;
/// <summary>
@@ -64,6 +75,8 @@ public sealed class TodoProvider : AIContextProvider
public TodoProvider(TodoProviderOptions? options = null)
{
this._instructions = options?.Instructions ?? DefaultInstructions;
this._suppressTodoListMessage = options?.SuppressTodoListMessage ?? false;
this._todoListMessageBuilder = options?.TodoListMessageBuilder;
this._sessionState = new ProviderSessionState<TodoState>(
_ => new TodoState(),
this.GetType().Name,
@@ -73,64 +86,146 @@ public sealed class TodoProvider : AIContextProvider
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
/// <inheritdoc />
public void Dispose()
{
this._nullSessionLock.Dispose();
}
/// <summary>
/// Gets all todo items from the session state.
/// </summary>
/// <remarks>
/// The returned <see cref="TodoItem"/> instances are the live objects from internal state.
/// Modifying their properties will mutate the provider's state directly.
/// </remarks>
/// <param name="session">The agent session to read todos from.</param>
/// <returns>A read-only list of all todo items.</returns>
public IReadOnlyList<TodoItem> GetAllTodos(AgentSession? session)
/// <returns>A list of all todo items. The items are live references to internal state.</returns>
public async Task<IReadOnlyList<TodoItem>> 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();
}
}
/// <summary>
/// Gets the remaining (incomplete) todo items from the session state.
/// </summary>
/// <remarks>
/// The returned <see cref="TodoItem"/> instances are the live objects from internal state.
/// Modifying their properties will mutate the provider's state directly.
/// </remarks>
/// <param name="session">The agent session to read todos from.</param>
/// <returns>A list of incomplete todo items.</returns>
public List<TodoItem> GetRemainingTodos(AgentSession? session)
/// <returns>A list of incomplete todo items. The items are live references to internal state.</returns>
public async Task<List<TodoItem>> 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();
}
}
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
TodoState state = this._sessionState.GetOrInitializeState(context.Session);
return new ValueTask<AIContext>(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<TodoItem> 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)
/// <summary>
/// Returns the per-session semaphore used to serialize all todo operations.
/// </summary>
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<TodoItemInput> todos) =>
async (List<TodoItemInput> todos) =>
{
var created = new List<TodoItem>();
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<TodoItem>();
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 @@ public sealed class TodoProvider : AIContextProvider
}),
AIFunctionFactory.Create(
(List<int> ids) =>
async (List<int> ids) =>
{
var idSet = new HashSet<int>(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<int>(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 @@ public sealed class TodoProvider : AIContextProvider
}),
AIFunctionFactory.Create(
(List<int> ids) =>
async (List<int> ids) =>
{
var idSet = new HashSet<int>(ids);
int removed = state.Items.RemoveAll(t => idSet.Contains(t.Id));
if (removed > 0)
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
this._sessionState.SaveState(session, state);
}
TodoState state = this._sessionState.GetOrInitializeState(session);
var idSet = new HashSet<int>(ids);
int removed = state.Items.RemoveAll(t => idSet.Contains(t.Id));
return removed;
if (removed > 0)
{
this._sessionState.SaveState(session, state);
}
return removed;
}
finally
{
sessionLock.Release();
}
},
new AIFunctionFactoryOptions
{
@@ -188,7 +303,20 @@ public sealed class TodoProvider : AIContextProvider
}),
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 @@ public sealed class TodoProvider : AIContextProvider
}),
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 @@ public sealed class TodoProvider : AIContextProvider
}),
];
}
internal static string FormatTodoListMessage(List<TodoItem> 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();
}
}
@@ -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.
/// </value>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to suppress injecting the todo list message
/// into the conversation context.
/// </summary>
/// <value>
/// When <see langword="false"/> (the default), a synthetic user message summarizing the current
/// todo list is injected at each invocation. When <see langword="true"/>, no message is injected.
/// </value>
public bool SuppressTodoListMessage { get; set; }
/// <summary>
/// Gets or sets a custom function that builds the todo list message text.
/// </summary>
/// <value>
/// When <see langword="null"/> (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.
/// </value>
public Func<IReadOnlyList<TodoItem>, string>? TodoListMessageBuilder { get; set; }
}
@@ -328,7 +328,7 @@ public class TodoProviderTests
#region Public Helper Method Tests
/// <summary>
/// Verify that GetAllTodos returns all items after adding via tools.
/// Verify that GetAllTodosAsync returns all items after adding via tools.
/// </summary>
[Fact]
public async Task PublicGetAllTodos_ReturnsAllItemsAsync()
@@ -348,7 +348,7 @@ public class TodoProviderTests
});
// Act
var todos = provider.GetAllTodos(session);
var todos = await provider.GetAllTodosAsync(session);
// Assert
Assert.Equal(2, todos.Count);
@@ -357,7 +357,7 @@ public class TodoProviderTests
}
/// <summary>
/// Verify that GetRemainingTodos returns only incomplete items.
/// Verify that GetRemainingTodosAsync returns only incomplete items.
/// </summary>
[Fact]
public async Task PublicGetRemainingTodos_ReturnsOnlyIncompleteAsync()
@@ -379,7 +379,7 @@ public class TodoProviderTests
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
// Act
var remaining = provider.GetRemainingTodos(session);
var remaining = await provider.GetRemainingTodosAsync(session);
// Assert
Assert.Single(remaining);
@@ -387,17 +387,17 @@ public class TodoProviderTests
}
/// <summary>
/// Verify that GetAllTodos returns empty list for a new session.
/// Verify that GetAllTodosAsync returns empty list for a new session.
/// </summary>
[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 class TodoProviderTests
}
#endregion
#region Message Injection Tests
/// <summary>
/// Verify that ProvideAIContextAsync injects a "none yet" message when the list is empty.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_InjectsEmptyTodoMessageAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().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);
}
/// <summary>
/// Verify that ProvideAIContextAsync injects a message listing existing todos with status.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_InjectsTodoListMessageAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().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<TodoItemInput>
{
new() { Title = "First" },
new() { Title = "Second", Description = "Has details" },
},
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 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);
}
/// <summary>
/// Verify that when SuppressTodoListMessage is true, no message is injected.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_SuppressTodoListMessage_NoMessageInjectedAsync()
{
// Arrange
var provider = new TodoProvider(new TodoProviderOptions { SuppressTodoListMessage = true });
var agent = new Mock<AIAgent>().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);
}
/// <summary>
/// Verify that a custom TodoListMessageBuilder is used when provided.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_CustomTodoListMessageBuilder_UsesCustomFormatterAsync()
{
// Arrange
var provider = new TodoProvider(new TodoProviderOptions
{
TodoListMessageBuilder = items => $"Custom: {items.Count} items",
});
var agent = new Mock<AIAgent>().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<TodoItemInput> { 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);
}
/// <summary>
/// Verify that SuppressTodoListMessage takes precedence over a set TodoListMessageBuilder.
/// </summary>
[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<AIAgent>().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);
}
/// <summary>
/// Verify that the list passed to TodoListMessageBuilder is a snapshot and mutating it does not affect state.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_BuilderReceivesSnapshot_MutationDoesNotAffectStateAsync()
{
// Arrange
IReadOnlyList<TodoItem>? capturedList = null;
var provider = new TodoProvider(new TodoProviderOptions
{
TodoListMessageBuilder = items =>
{
capturedList = items;
return "snapshot test";
},
});
var agent = new Mock<AIAgent>().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<TodoItemInput> { 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<TodoItem>)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
/// <summary>
/// Verify that concurrent add operations do not produce duplicate IDs.
/// </summary>
[Fact]
public async Task ConcurrentAdds_ProduceUniqueIdsAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().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<TodoItemInput> { 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);
}
/// <summary>
/// Verify that concurrent add and complete operations serialize correctly.
/// </summary>
[Fact]
public async Task ConcurrentAddAndComplete_SerializesCorrectlyAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().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<TodoItemInput>
{
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<TodoItemInput> { new() { Title = "New A" }, new() { Title = "New B" } },
}).AsTask(),
addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "New C" } },
}).AsTask(),
completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 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<int> { 1, 2, 3 }, completedIds);
}
#endregion
}