Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides extension methods for reading human-in-the-loop tool approval state from an <see cref="AgentSession"/>.
/// </summary>
public static class ToolApprovalAgentSessionExtensions
{
/// <summary>
/// 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 <see cref="ToolApprovalResponseContent"/>.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <param name="session">The agent session to read pending approval requests from.</param>
/// <param name="requests">When this method returns, contains the pending approval requests if any were found; otherwise, <see langword="null"/>.</param>
/// <returns><see langword="true"/> if at least one pending approval request was found; <see langword="false"/> otherwise.</returns>
public static bool TryGetPendingToolApprovalRequests(
this AgentSession session,
[NotNullWhen(true)] out IReadOnlyList<ToolApprovalRequestContent>? requests)
{
_ = Throw.IfNull(session);

if (session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
ApprovalResponseBindingChatClient.StateBagKey,
out var pending,
AgentJsonUtilities.DefaultOptions)
&& pending is { Count: > 0 })
{
requests = pending;
return true;
}

requests = null;
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?> { ["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<FunctionCallContent>(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((_, _, _) =>
Expand Down
Loading