mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
384e26abd7
commit
e9a6d43237
@@ -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);
|
||||
}
|
||||
|
||||
+5
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user