diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ToolApprovalAgentSessionExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ToolApprovalAgentSessionExtensions.cs new file mode 100644 index 0000000000..d850ef6d62 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ToolApprovalAgentSessionExtensions.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides extension methods for reading human-in-the-loop tool approval state from an . +/// +public static class ToolApprovalAgentSessionExtensions +{ + /// + /// Attempts to retrieve the tool approval requests that the framework has surfaced for the specified session and + /// that have not yet been answered with a matching . + /// + /// + /// + /// A host with durable sessions can use this after deserializing a session to discover that the conversation is + /// paused on a pending approval, restore its approval UI, and submit the approval later using + /// the request id. + /// + /// + /// The returned requests are snapshots of the model-originated requests. Mutating them does not change the + /// recorded state used to bind an incoming approval response. + /// + /// + /// The agent session to read pending approval requests from. + /// When this method returns, contains the pending approval requests if any were found; otherwise, . + /// if at least one pending approval request was found; otherwise. + public static bool TryGetPendingToolApprovalRequests( + this AgentSession session, + [NotNullWhen(true)] out IReadOnlyList? requests) + { + _ = Throw.IfNull(session); + + if (session.StateBag.TryGetValue>( + ApprovalResponseBindingChatClient.StateBagKey, + out var pending, + AgentJsonUtilities.DefaultOptions) + && pending is { Count: > 0 }) + { + requests = pending; + return true; + } + + requests = null; + return false; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs index e89e2eae97..d279db3fce 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs @@ -270,6 +270,55 @@ public async Task GetResponseAsync_NoSession_PassesThroughUnvalidatedAsync() Assert.Contains(capture.Messages!.SelectMany(m => m.Contents), c => c is ToolApprovalResponseContent); } + [Fact] + public async Task TryGetPendingToolApprovalRequests_SurvivesSessionStateRoundTripAsync() + { + // Arrange — a run stops on an approval request, then the session is persisted and reloaded. + var session = new ChatClientAgentSession(); + var call = new FunctionCallContent("call1", "get_weather", new Dictionary { ["location"] = "Beijing" }); + await RecordRequestAsync(session, new ToolApprovalRequestContent(RequestId, call)); + + var restored = new ChatClientAgentSession( + stateBag: AgentSessionStateBag.Deserialize(session.StateBag.Serialize())); + + // Act + var found = restored.TryGetPendingToolApprovalRequests(out var pending); + + // Assert — the host can discover the pending approval without reading private state bag keys. + Assert.True(found); + var request = Assert.Single(pending!); + Assert.Equal(RequestId, request.RequestId); + var pendingCall = Assert.IsType(request.ToolCall); + Assert.Equal("get_weather", pendingCall.Name); + Assert.Equal("call1", pendingCall.CallId); + } + + [Fact] + public async Task TryGetPendingToolApprovalRequests_AfterResponseIsConsumed_ReturnsFalseAsync() + { + // Arrange — record a request, then answer it. + var session = new ChatClientAgentSession(); + await RecordRequestAsync(session, new ToolApprovalRequestContent(RequestId, new FunctionCallContent("call1", "toolA"))); + Assert.True(session.TryGetPendingToolApprovalRequests(out _)); + + var decorator = new ApprovalResponseBindingChatClient(CreateCapturingChatClient(new Capture())); + var approval = new ToolApprovalResponseContent(RequestId, approved: true, new FunctionCallContent("call1", "toolA")); + + // Act + await RunAsync(decorator, session, [new ChatMessage(ChatRole.User, [approval])]); + + // Assert — the answered request is no longer pending. + Assert.False(session.TryGetPendingToolApprovalRequests(out var pending)); + Assert.Null(pending); + } + + [Fact] + public void TryGetPendingToolApprovalRequests_NoApprovalState_ReturnsFalse() + { + Assert.False(new ChatClientAgentSession().TryGetPendingToolApprovalRequests(out var pending)); + Assert.Null(pending); + } + private static async Task RecordRequestAsync(ChatClientAgentSession session, ToolApprovalRequestContent request) { var inner = CreateMockChatClient((_, _, _) =>