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