From 54655a6ad25e7579ca6b2506320c0903785ad508 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:52:33 +0000
Subject: [PATCH 1/5] Add a TODO AIContextProvider
---
.../Magentic/Todo/TodoItem.cs | 38 ++++
.../Magentic/Todo/TodoItemInput.cs | 26 +++
.../Magentic/Todo/TodoProvider.cs | 210 ++++++++++++++++++
.../Magentic/Todo/TodoState.cs | 28 +++
4 files changed, 302 insertions(+)
create mode 100644 dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoItem.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoItemInput.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoProvider.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoState.cs
diff --git a/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoItem.cs b/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoItem.cs
new file mode 100644
index 0000000000..b9540ffcbd
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoItem.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Represents a single todo item managed by the .
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class TodoItem
+{
+ ///
+ /// Gets or sets the unique identifier for this todo item.
+ ///
+ [JsonPropertyName("id")]
+ public int Id { get; set; }
+
+ ///
+ /// Gets or sets the title of this todo item.
+ ///
+ [JsonPropertyName("title")]
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets an optional description providing additional details about this todo item.
+ ///
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this todo item has been completed.
+ ///
+ [JsonPropertyName("isComplete")]
+ public bool IsComplete { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoItemInput.cs b/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoItemInput.cs
new file mode 100644
index 0000000000..aa15a3e436
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoItemInput.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Represents the input for creating a new todo item via the .
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+internal sealed class TodoItemInput
+{
+ ///
+ /// Gets or sets the title of the todo item to create.
+ ///
+ [JsonPropertyName("title")]
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets an optional description providing additional details about the todo item.
+ ///
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoProvider.cs b/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoProvider.cs
new file mode 100644
index 0000000000..4ba72909c4
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoProvider.cs
@@ -0,0 +1,210 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// An that provides todo management tools and instructions
+/// to an agent for tracking work items during long-running complex tasks.
+///
+///
+///
+/// The enables agents to create, complete, remove, and query todo items
+/// as part of their planning and execution workflow. Todo state is stored in the session's
+/// and persists across agent invocations within the same session.
+///
+///
+/// This provider exposes the following tools to the agent:
+///
+/// - AddTodos — Add one or more todo items, each with a title and optional description.
+/// - CompleteTodos — Mark one or more todo items as complete by their IDs.
+/// - RemoveTodos — Remove one or more todo items by their IDs.
+/// - GetRemainingTodos — Retrieve only incomplete todo items.
+/// - GetAllTodos — Retrieve all todo items (complete and incomplete).
+///
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class TodoProvider : AIContextProvider
+{
+ private const string DefaultInstructions =
+ """
+ You have access to a todo list for tracking work items.
+ While planning, make sure that you break down complex tasks into manageable todo items and add them to the list.
+ Ask questions from the user where clarification is needed to create effective todos.
+ If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant ones.
+ During execution, use the todo list to keep track of what needs to be done, mark items as complete when finished, and remove any items that are no longer needed.
+ When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant items or adding new ones as needed.
+
+ Use these tools to manage your tasks:
+ - Use AddTodos to break down complex work into trackable items (supports adding one or many at once).
+ - Use CompleteTodos to mark items as done when finished (supports one or many at once).
+ - Use GetRemainingTodos to check what work is still pending.
+ - Use GetAllTodos to review the full list including completed items.
+ - Use RemoveTodos to remove items that are no longer needed (supports one or many at once).
+ """;
+
+ private readonly ProviderSessionState _sessionState;
+ private IReadOnlyList? _stateKeys;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public TodoProvider()
+ {
+ this._sessionState = new ProviderSessionState(
+ _ => new TodoState(),
+ this.GetType().Name,
+ AgentJsonUtilities.DefaultOptions);
+ }
+
+ ///
+ public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
+
+ ///
+ /// Gets all todo items from the session state.
+ ///
+ /// The agent session to read todos from.
+ /// A read-only list of all todo items.
+ public IReadOnlyList GetAllTodos(AgentSession? session)
+ {
+ return this._sessionState.GetOrInitializeState(session).Items;
+ }
+
+ ///
+ /// Gets the remaining (incomplete) todo items from the session state.
+ ///
+ /// The agent session to read todos from.
+ /// A list of incomplete todo items.
+ public List GetRemainingTodos(AgentSession? session)
+ {
+ return this._sessionState.GetOrInitializeState(session).Items.Where(t => !t.IsComplete).ToList();
+ }
+
+ ///
+ protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ {
+ TodoState state = this._sessionState.GetOrInitializeState(context.Session);
+
+ return new ValueTask(new AIContext
+ {
+ Instructions = DefaultInstructions,
+ Tools = this.CreateTools(state, context.Session),
+ });
+ }
+
+ private AITool[] CreateTools(TodoState state, AgentSession? session)
+ {
+ var serializerOptions = AgentJsonUtilities.DefaultOptions;
+
+ return
+ [
+ AIFunctionFactory.Create(
+ (List todos) =>
+ {
+ var created = new List();
+ foreach (var input in todos)
+ {
+ var item = new TodoItem
+ {
+ Id = state.NextId++,
+ Title = input.Title,
+ Description = input.Description,
+ };
+ state.Items.Add(item);
+ created.Add(item);
+ }
+
+ this._sessionState.SaveState(session, state);
+ return created;
+ },
+ new AIFunctionFactoryOptions
+ {
+ Name = "AddTodos",
+ Description = "Add one or more todo items. Each item has a title and an optional description. Returns the list of created todo items.",
+ SerializerOptions = serializerOptions,
+ }),
+
+ AIFunctionFactory.Create(
+ (List ids) =>
+ {
+ int completed = 0;
+ foreach (int id in ids)
+ {
+ TodoItem? item = state.Items.FirstOrDefault(t => t.Id == id);
+ if (item is not null)
+ {
+ item.IsComplete = true;
+ completed++;
+ }
+ }
+
+ if (completed > 0)
+ {
+ this._sessionState.SaveState(session, state);
+ }
+
+ return completed;
+ },
+ new AIFunctionFactoryOptions
+ {
+ Name = "CompleteTodos",
+ Description = "Mark one or more todo items as complete by their IDs. Returns the number of items that were found and marked complete.",
+ SerializerOptions = serializerOptions,
+ }),
+
+ AIFunctionFactory.Create(
+ (List ids) =>
+ {
+ int removed = 0;
+ foreach (int id in ids)
+ {
+ TodoItem? item = state.Items.FirstOrDefault(t => t.Id == id);
+ if (item is not null)
+ {
+ state.Items.Remove(item);
+ removed++;
+ }
+ }
+
+ if (removed > 0)
+ {
+ this._sessionState.SaveState(session, state);
+ }
+
+ return removed;
+ },
+ new AIFunctionFactoryOptions
+ {
+ Name = "RemoveTodos",
+ Description = "Remove one or more todo items by their IDs. Returns the number of items that were found and removed.",
+ SerializerOptions = serializerOptions,
+ }),
+
+ AIFunctionFactory.Create(
+ () => state.Items.Where(t => !t.IsComplete).ToList(),
+ new AIFunctionFactoryOptions
+ {
+ Name = "GetRemainingTodos",
+ Description = "Retrieve the list of incomplete todo items.",
+ SerializerOptions = serializerOptions,
+ }),
+
+ AIFunctionFactory.Create(
+ () => state.Items,
+ new AIFunctionFactoryOptions
+ {
+ Name = "GetAllTodos",
+ Description = "Retrieve the full list of todo items, both complete and incomplete.",
+ SerializerOptions = serializerOptions,
+ }),
+ ];
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoState.cs b/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoState.cs
new file mode 100644
index 0000000000..5b62d6d1eb
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoState.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Represents the state of the todo list managed by the ,
+/// stored in the session's .
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+internal sealed class TodoState
+{
+ ///
+ /// Gets the list of todo items.
+ ///
+ [JsonPropertyName("items")]
+ public List Items { get; set; } = [];
+
+ ///
+ /// Gets or sets the next ID to assign to a new todo item.
+ ///
+ [JsonPropertyName("nextId")]
+ public int NextId { get; set; } = 1;
+}
From af9dcc223af01cebaa52de2f7244021309925934 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:56:26 +0000
Subject: [PATCH 2/5] Add unit tests
---
.../Magentic/Todo/TodoProviderTests.cs | 445 ++++++++++++++++++
1 file changed, 445 insertions(+)
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Magentic/Todo/TodoProviderTests.cs
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Magentic/Todo/TodoProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Magentic/Todo/TodoProviderTests.cs
new file mode 100644
index 0000000000..726de7e50a
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Magentic/Todo/TodoProviderTests.cs
@@ -0,0 +1,445 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Moq;
+
+namespace Microsoft.Agents.AI.UnitTests;
+
+///
+/// Unit tests for the class.
+///
+public class TodoProviderTests
+{
+ #region ProvideAIContextAsync Tests
+
+ ///
+ /// Verify that the provider returns tools and instructions.
+ ///
+ [Fact]
+ public async Task ProvideAIContextAsync_ReturnsToolsAndInstructionsAsync()
+ {
+ // 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.Instructions);
+ Assert.NotNull(result.Tools);
+ Assert.Equal(5, result.Tools!.Count());
+ }
+
+ #endregion
+
+ #region AddTodos Tests
+
+ ///
+ /// Verify that AddTodos creates a new todo item when given a single item.
+ ///
+ [Fact]
+ public async Task AddTodos_CreatesSingleItemAsync()
+ {
+ // Arrange
+ var (tools, state) = await CreateToolsWithStateAsync();
+ AIFunction addTodos = GetTool(tools, "AddTodos");
+
+ // Act
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = "Test todo", Description = "A test description" } },
+ });
+
+ // Assert
+ Assert.Single(state.Items);
+ Assert.Equal("Test todo", state.Items[0].Title);
+ Assert.Equal("A test description", state.Items[0].Description);
+ Assert.False(state.Items[0].IsComplete);
+ Assert.Equal(1, state.Items[0].Id);
+ }
+
+ ///
+ /// Verify that AddTodos creates multiple items with incrementing IDs.
+ ///
+ [Fact]
+ public async Task AddTodos_CreatesMultipleItemsWithIncrementingIdsAsync()
+ {
+ // Arrange
+ var (tools, state) = await CreateToolsWithStateAsync();
+ AIFunction addTodos = GetTool(tools, "AddTodos");
+
+ // Act
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List
+ {
+ new() { Title = "First", Description = null },
+ new() { Title = "Second", Description = null },
+ new() { Title = "Third", Description = "With description" },
+ },
+ });
+
+ // Assert
+ Assert.Equal(3, state.Items.Count);
+ Assert.Equal(1, state.Items[0].Id);
+ Assert.Equal("First", state.Items[0].Title);
+ Assert.Equal(2, state.Items[1].Id);
+ Assert.Equal("Second", state.Items[1].Title);
+ Assert.Equal(3, state.Items[2].Id);
+ Assert.Equal("Third", state.Items[2].Title);
+ Assert.Equal("With description", state.Items[2].Description);
+ }
+
+ #endregion
+
+ #region CompleteTodos Tests
+
+ ///
+ /// Verify that CompleteTodos marks an item as complete.
+ ///
+ [Fact]
+ public async Task CompleteTodos_MarksItemCompleteAsync()
+ {
+ // Arrange
+ var (tools, state) = await CreateToolsWithStateAsync();
+ AIFunction addTodos = GetTool(tools, "AddTodos");
+ AIFunction completeTodos = GetTool(tools, "CompleteTodos");
+ await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Test", Description = null } } });
+
+ // Act
+ object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } });
+
+ // Assert
+ Assert.True(state.Items[0].IsComplete);
+ Assert.Equal(1, GetIntResult(result));
+ }
+
+ ///
+ /// Verify that CompleteTodos marks multiple items as complete.
+ ///
+ [Fact]
+ public async Task CompleteTodos_MarksMultipleItemsCompleteAsync()
+ {
+ // Arrange
+ var (tools, state) = await CreateToolsWithStateAsync();
+ AIFunction addTodos = GetTool(tools, "AddTodos");
+ AIFunction completeTodos = GetTool(tools, "CompleteTodos");
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
+ });
+
+ // Act
+ object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1, 3 } });
+
+ // Assert
+ Assert.True(state.Items[0].IsComplete);
+ Assert.False(state.Items[1].IsComplete);
+ Assert.True(state.Items[2].IsComplete);
+ Assert.Equal(2, GetIntResult(result));
+ }
+
+ ///
+ /// Verify that CompleteTodos returns zero for non-existent IDs.
+ ///
+ [Fact]
+ public async Task CompleteTodos_ReturnsZeroForMissingIdsAsync()
+ {
+ // Arrange
+ var (tools, _) = await CreateToolsWithStateAsync();
+ AIFunction completeTodos = GetTool(tools, "CompleteTodos");
+
+ // Act
+ object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 999 } });
+
+ // Assert
+ Assert.Equal(0, GetIntResult(result));
+ }
+
+ #endregion
+
+ #region RemoveTodos Tests
+
+ ///
+ /// Verify that RemoveTodos removes an item.
+ ///
+ [Fact]
+ public async Task RemoveTodos_RemovesItemAsync()
+ {
+ // Arrange
+ var (tools, state) = await CreateToolsWithStateAsync();
+ AIFunction addTodos = GetTool(tools, "AddTodos");
+ AIFunction removeTodos = GetTool(tools, "RemoveTodos");
+ await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Test", Description = null } } });
+
+ // Act
+ object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } });
+
+ // Assert
+ Assert.Empty(state.Items);
+ Assert.Equal(1, GetIntResult(result));
+ }
+
+ ///
+ /// Verify that RemoveTodos removes multiple items.
+ ///
+ [Fact]
+ public async Task RemoveTodos_RemovesMultipleItemsAsync()
+ {
+ // Arrange
+ var (tools, state) = await CreateToolsWithStateAsync();
+ AIFunction addTodos = GetTool(tools, "AddTodos");
+ AIFunction removeTodos = GetTool(tools, "RemoveTodos");
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
+ });
+
+ // Act
+ object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1, 3 } });
+
+ // Assert
+ Assert.Single(state.Items);
+ Assert.Equal("Second", state.Items[0].Title);
+ Assert.Equal(2, GetIntResult(result));
+ }
+
+ ///
+ /// Verify that RemoveTodos returns zero for non-existent IDs.
+ ///
+ [Fact]
+ public async Task RemoveTodos_ReturnsZeroForMissingIdsAsync()
+ {
+ // Arrange
+ var (tools, _) = await CreateToolsWithStateAsync();
+ AIFunction removeTodos = GetTool(tools, "RemoveTodos");
+
+ // Act
+ object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 999 } });
+
+ // Assert
+ Assert.Equal(0, GetIntResult(result));
+ }
+
+ #endregion
+
+ #region GetRemainingTodos Tests
+
+ ///
+ /// Verify that GetRemainingTodos returns only incomplete items.
+ ///
+ [Fact]
+ public async Task GetRemainingTodos_ReturnsOnlyIncompleteAsync()
+ {
+ // Arrange
+ var (tools, _) = await CreateToolsWithStateAsync();
+ AIFunction addTodos = GetTool(tools, "AddTodos");
+ AIFunction completeTodos = GetTool(tools, "CompleteTodos");
+ AIFunction getRemainingTodos = GetTool(tools, "GetRemainingTodos");
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
+ });
+ await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } });
+
+ // Act
+ object? result = await getRemainingTodos.InvokeAsync(new AIFunctionArguments());
+
+ // Assert
+ var remaining = GetArrayResult(result);
+ Assert.Single(remaining);
+ Assert.Equal("Pending", remaining[0].GetProperty("title").GetString());
+ }
+
+ #endregion
+
+ #region GetAllTodos Tests
+
+ ///
+ /// Verify that GetAllTodos returns all items.
+ ///
+ [Fact]
+ public async Task GetAllTodos_ReturnsAllItemsAsync()
+ {
+ // Arrange
+ var (tools, _) = await CreateToolsWithStateAsync();
+ AIFunction addTodos = GetTool(tools, "AddTodos");
+ AIFunction completeTodos = GetTool(tools, "CompleteTodos");
+ AIFunction getAllTodos = GetTool(tools, "GetAllTodos");
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
+ });
+ await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } });
+
+ // Act
+ object? result = await getAllTodos.InvokeAsync(new AIFunctionArguments());
+
+ // Assert
+ var all = GetArrayResult(result);
+ Assert.Equal(2, all.Count);
+ }
+
+ #endregion
+
+ #region State Persistence Tests
+
+ ///
+ /// Verify that state persists in the session StateBag.
+ ///
+ [Fact]
+ public async Task State_PersistsInSessionStateBagAsync()
+ {
+ // 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 — first invocation adds a todo
+ AIContext result1 = await provider.InvokingAsync(context);
+ AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "AddTodos");
+ await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Persisted", Description = null } } });
+
+ // Second invocation should see the same state
+ AIContext result2 = await provider.InvokingAsync(context);
+ AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "GetAllTodos");
+ object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
+
+ // Assert
+ var all = GetArrayResult(allResult);
+ Assert.Single(all);
+ Assert.Equal("Persisted", all[0].GetProperty("title").GetString());
+ }
+
+ #endregion
+
+ #region Public Helper Method Tests
+
+ ///
+ /// Verify that GetAllTodos returns all items after adding via tools.
+ ///
+ [Fact]
+ public async Task PublicGetAllTodos_ReturnsAllItemsAsync()
+ {
+ // 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!, "AddTodos");
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = "First", Description = null }, new() { Title = "Second", Description = null } },
+ });
+
+ // Act
+ var todos = provider.GetAllTodos(session);
+
+ // Assert
+ Assert.Equal(2, todos.Count);
+ Assert.Equal("First", todos[0].Title);
+ Assert.Equal("Second", todos[1].Title);
+ }
+
+ ///
+ /// Verify that GetRemainingTodos returns only incomplete items.
+ ///
+ [Fact]
+ public async Task PublicGetRemainingTodos_ReturnsOnlyIncompleteAsync()
+ {
+ // 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!, "AddTodos");
+ AIFunction completeTodos = GetTool(result.Tools!, "CompleteTodos");
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
+ });
+ await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } });
+
+ // Act
+ var remaining = provider.GetRemainingTodos(session);
+
+ // Assert
+ Assert.Single(remaining);
+ Assert.Equal("Pending", remaining[0].Title);
+ }
+
+ ///
+ /// Verify that GetAllTodos returns empty list for a new session.
+ ///
+ [Fact]
+ public void PublicGetAllTodos_ReturnsEmptyForNewSession()
+ {
+ // Arrange
+ var provider = new TodoProvider();
+ var session = new ChatClientAgentSession();
+
+ // Act
+ var todos = provider.GetAllTodos(session);
+
+ // Assert
+ Assert.Empty(todos);
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ private static async Task<(IEnumerable Tools, TodoState State)> CreateToolsWithStateAsync()
+ {
+ 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);
+
+ // Retrieve the state from the session to verify mutations
+ session.StateBag.TryGetValue("TodoProvider", out var state, AgentJsonUtilities.DefaultOptions);
+
+ return (result.Tools!, state!);
+ }
+
+ private static AIFunction GetTool(IEnumerable tools, string name)
+ {
+ return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name);
+ }
+
+ private static int GetIntResult(object? result)
+ {
+ var element = Assert.IsType(result);
+ return element.GetInt32();
+ }
+
+ private static List GetArrayResult(object? result)
+ {
+ var element = Assert.IsType(result);
+ return element.EnumerateArray().ToList();
+ }
+
+ #endregion
+}
From 3252b426658a145fd484b49f5f0ad666f750a443 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Mon, 13 Apr 2026 18:21:31 +0000
Subject: [PATCH 3/5] Address PR comments
---
.../Magentic/Todo/TodoProvider.cs | 35 ++++++++++++-------
.../Magentic/Todo/TodoProviderTests.cs | 29 ++++++++++++++-
2 files changed, 50 insertions(+), 14 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoProvider.cs b/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoProvider.cs
index 4ba72909c4..7a5ed4e907 100644
--- a/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoProvider.cs
@@ -100,6 +100,8 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co
});
}
+ // 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)
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
@@ -135,11 +137,11 @@ private AITool[] CreateTools(TodoState state, AgentSession? session)
AIFunctionFactory.Create(
(List ids) =>
{
+ var idSet = new HashSet(ids);
int completed = 0;
- foreach (int id in ids)
+ foreach (TodoItem item in state.Items)
{
- TodoItem? item = state.Items.FirstOrDefault(t => t.Id == id);
- if (item is not null)
+ if (!item.IsComplete && idSet.Contains(item.Id))
{
item.IsComplete = true;
completed++;
@@ -163,16 +165,8 @@ private AITool[] CreateTools(TodoState state, AgentSession? session)
AIFunctionFactory.Create(
(List ids) =>
{
- int removed = 0;
- foreach (int id in ids)
- {
- TodoItem? item = state.Items.FirstOrDefault(t => t.Id == id);
- if (item is not null)
- {
- state.Items.Remove(item);
- removed++;
- }
- }
+ var idSet = new HashSet(ids);
+ int removed = state.Items.RemoveAll(t => idSet.Contains(t.Id));
if (removed > 0)
{
@@ -188,6 +182,21 @@ private AITool[] CreateTools(TodoState state, AgentSession? session)
SerializerOptions = serializerOptions,
}),
+ AIFunctionFactory.Create(
+ () =>
+ {
+ state.Items.Clear();
+ state.NextId = 1;
+ this._sessionState.SaveState(session, state);
+ return "All todos cleared.";
+ },
+ new AIFunctionFactoryOptions
+ {
+ Name = "ClearTodos",
+ Description = "Remove all todo items from the list.",
+ SerializerOptions = serializerOptions,
+ }),
+
AIFunctionFactory.Create(
() => state.Items.Where(t => !t.IsComplete).ToList(),
new AIFunctionFactoryOptions
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Magentic/Todo/TodoProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Magentic/Todo/TodoProviderTests.cs
index 726de7e50a..4f82cf4fcb 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Magentic/Todo/TodoProviderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Magentic/Todo/TodoProviderTests.cs
@@ -36,7 +36,7 @@ public async Task ProvideAIContextAsync_ReturnsToolsAndInstructionsAsync()
// Assert
Assert.NotNull(result.Instructions);
Assert.NotNull(result.Tools);
- Assert.Equal(5, result.Tools!.Count());
+ Assert.Equal(6, result.Tools!.Count());
}
#endregion
@@ -232,6 +232,33 @@ public async Task RemoveTodos_ReturnsZeroForMissingIdsAsync()
#endregion
+ #region ClearTodos Tests
+
+ ///
+ /// Verify that ClearTodos removes all items and resets the ID counter.
+ ///
+ [Fact]
+ public async Task ClearTodos_RemovesAllItemsAsync()
+ {
+ // Arrange
+ var (tools, state) = await CreateToolsWithStateAsync();
+ AIFunction addTodos = GetTool(tools, "AddTodos");
+ AIFunction clearTodos = GetTool(tools, "ClearTodos");
+ await addTodos.InvokeAsync(new AIFunctionArguments()
+ {
+ ["todos"] = new List { new() { Title = "First", Description = null }, new() { Title = "Second", Description = null } },
+ });
+
+ // Act
+ await clearTodos.InvokeAsync(new AIFunctionArguments());
+
+ // Assert
+ Assert.Empty(state.Items);
+ Assert.Equal(1, state.NextId);
+ }
+
+ #endregion
+
#region GetRemainingTodos Tests
///
From 238950cac0dcc66b3a214ff8d12e20b1d71a9218 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Tue, 14 Apr 2026 10:12:18 +0000
Subject: [PATCH 4/5] Address PR comments
---
.../Microsoft.Agents.AI/AgentJsonUtilities.cs | 9 +++++++
.../{Magentic => Harness}/Todo/TodoItem.cs | 0
.../Todo/TodoItemInput.cs | 0
.../Todo/TodoProvider.cs | 15 -----------
.../{Magentic => Harness}/Todo/TodoState.cs | 0
.../Todo/TodoProviderTests.cs | 27 -------------------
6 files changed, 9 insertions(+), 42 deletions(-)
rename dotnet/src/Microsoft.Agents.AI/{Magentic => Harness}/Todo/TodoItem.cs (100%)
rename dotnet/src/Microsoft.Agents.AI/{Magentic => Harness}/Todo/TodoItemInput.cs (100%)
rename dotnet/src/Microsoft.Agents.AI/{Magentic => Harness}/Todo/TodoProvider.cs (94%)
rename dotnet/src/Microsoft.Agents.AI/{Magentic => Harness}/Todo/TodoState.cs (100%)
rename dotnet/tests/Microsoft.Agents.AI.UnitTests/{Magentic => Harness}/Todo/TodoProviderTests.cs (94%)
diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs
index 96ec6dbecb..a531f650fa 100644
--- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs
+++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using System.Text.Json;
@@ -69,6 +70,14 @@ private static JsonSerializerOptions CreateDefaultOptions()
[JsonSerializable(typeof(TextSearchProvider.TextSearchProviderState))]
[JsonSerializable(typeof(ChatHistoryMemoryProvider.State))]
+ // Harness types
+ [JsonSerializable(typeof(TodoState))]
+ [JsonSerializable(typeof(TodoItem))]
+ [JsonSerializable(typeof(TodoItemInput))]
+ [JsonSerializable(typeof(List), TypeInfoPropertyName = "IntList")]
+ [JsonSerializable(typeof(List), TypeInfoPropertyName = "TodoItemList")]
+ [JsonSerializable(typeof(List), TypeInfoPropertyName = "TodoItemInputList")]
+
[ExcludeFromCodeCoverage]
internal sealed partial class JsonContext : JsonSerializerContext;
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoItem.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoItem.cs
similarity index 100%
rename from dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoItem.cs
rename to dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoItem.cs
diff --git a/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoItemInput.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoItemInput.cs
similarity index 100%
rename from dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoItemInput.cs
rename to dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoItemInput.cs
diff --git a/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs
similarity index 94%
rename from dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoProvider.cs
rename to dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs
index 7a5ed4e907..e39db5e197 100644
--- a/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs
@@ -182,21 +182,6 @@ private AITool[] CreateTools(TodoState state, AgentSession? session)
SerializerOptions = serializerOptions,
}),
- AIFunctionFactory.Create(
- () =>
- {
- state.Items.Clear();
- state.NextId = 1;
- this._sessionState.SaveState(session, state);
- return "All todos cleared.";
- },
- new AIFunctionFactoryOptions
- {
- Name = "ClearTodos",
- Description = "Remove all todo items from the list.",
- SerializerOptions = serializerOptions,
- }),
-
AIFunctionFactory.Create(
() => state.Items.Where(t => !t.IsComplete).ToList(),
new AIFunctionFactoryOptions
diff --git a/dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoState.cs
similarity index 100%
rename from dotnet/src/Microsoft.Agents.AI/Magentic/Todo/TodoState.cs
rename to dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoState.cs
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Magentic/Todo/TodoProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs
similarity index 94%
rename from dotnet/tests/Microsoft.Agents.AI.UnitTests/Magentic/Todo/TodoProviderTests.cs
rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs
index 4f82cf4fcb..d070e78e83 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Magentic/Todo/TodoProviderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs
@@ -232,33 +232,6 @@ public async Task RemoveTodos_ReturnsZeroForMissingIdsAsync()
#endregion
- #region ClearTodos Tests
-
- ///
- /// Verify that ClearTodos removes all items and resets the ID counter.
- ///
- [Fact]
- public async Task ClearTodos_RemovesAllItemsAsync()
- {
- // Arrange
- var (tools, state) = await CreateToolsWithStateAsync();
- AIFunction addTodos = GetTool(tools, "AddTodos");
- AIFunction clearTodos = GetTool(tools, "ClearTodos");
- await addTodos.InvokeAsync(new AIFunctionArguments()
- {
- ["todos"] = new List { new() { Title = "First", Description = null }, new() { Title = "Second", Description = null } },
- });
-
- // Act
- await clearTodos.InvokeAsync(new AIFunctionArguments());
-
- // Assert
- Assert.Empty(state.Items);
- Assert.Equal(1, state.NextId);
- }
-
- #endregion
-
#region GetRemainingTodos Tests
///
From 5b64235b97a954a96005bba72c933665859b465c Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Tue, 14 Apr 2026 10:28:42 +0000
Subject: [PATCH 5/5] Fix test after removing one tool
---
.../Harness/Todo/TodoProviderTests.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 d070e78e83..726de7e50a 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs
@@ -36,7 +36,7 @@ public async Task ProvideAIContextAsync_ReturnsToolsAndInstructionsAsync()
// Assert
Assert.NotNull(result.Instructions);
Assert.NotNull(result.Tools);
- Assert.Equal(6, result.Tools!.Count());
+ Assert.Equal(5, result.Tools!.Count());
}
#endregion