diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs index 641e6eb3524..dd2cdefb039 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs @@ -115,6 +115,13 @@ protected override async Task RunCoreAsync( return new AgentResponse(new ChatMessage(ChatRole.Assistant, [nextQueuedItem])); } + // When the caller did not supply a session, create one and use it for every inner call. + // The auto-approval loop re-invokes the inner agent with only the injected approval + // responses; without a session the inner agent has no conversation history to reconstruct + // the original request, which produces an empty request to the underlying service. Threading + // a session preserves the history across re-invocations. + session ??= await this.InnerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + // 3. Call the inner agent in a loop. If the inner agent returns approval requests // that are ALL auto-approved by standing rules, we immediately re-call with the // collected approval responses injected. This avoids returning empty responses. @@ -158,6 +165,11 @@ protected override async IAsyncEnumerable RunCoreStreamingA yield break; } + // When the caller did not supply a session, create one and use it for every inner call so + // conversation history is preserved across auto-approval re-invocations. See the non-streaming + // RunCoreAsync for details. + session ??= await this.InnerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + // 3. Stream from the inner agent in a loop. If all approval requests from the stream // are auto-approved by standing rules, we immediately re-stream with the collected // approval responses injected. This avoids returning empty streams. diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs index 608f562e834..ebe52d3068a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs @@ -1962,8 +1962,165 @@ [new ChatMessage(ChatRole.User, "Hi")], } /// - /// Verify that when auto-approval rule does not match, request is surfaced to the caller. + /// Verify that when no session is supplied, the agent creates one and threads it to the inner + /// agent across auto-approval re-invocations. Without a session, the inner agent would receive + /// only the injected approval response (with no history) on the second call, producing an empty + /// request to the underlying service (repro for issue #7210). /// + [Fact] + public async Task RunAsync_AutoApprovalRule_NoSession_CreatesAndThreadsSessionAsync() + { + // Arrange + var createdSession = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool")); + + var capturedSessions = new List(); + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(new ValueTask(createdSession)); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>( + (_, session, _, _) => capturedSessions.Add(session)) + .ReturnsAsync(() => + { + callCount++; + if (callCount == 1) + { + return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]); + } + + return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]); + }); + + var options = new ToolApprovalAgentOptions + { + AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule] + }; + var agent = new ToolApprovalAgent(innerAgent.Object, options); + + // Act — invoke WITHOUT a session. + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")]); + + // Assert — auto-approval re-invoked the inner agent, and both calls received the same, + // non-null session so conversation history is preserved across the re-invocation. + Assert.Equal(2, callCount); + Assert.Equal("Done", response.Text); + Assert.Equal(2, capturedSessions.Count); + Assert.All(capturedSessions, s => Assert.Same(createdSession, s)); + } + + /// + /// Streaming counterpart of : + /// when no session is supplied, the streaming path also creates one and threads it to the inner + /// agent across auto-approval re-invocations. + /// + [Fact] + public async Task RunStreamingAsync_AutoApprovalRule_NoSession_CreatesAndThreadsSessionAsync() + { + // Arrange + var createdSession = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool")); + + var capturedSessions = new List(); + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(new ValueTask(createdSession)); + innerAgent + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns, AgentSession?, AgentRunOptions?, CancellationToken>( + (_, session, _, ct) => + { + capturedSessions.Add(session); + callCount++; + AgentResponseUpdate[] streamUpdates = callCount == 1 + ? [new AgentResponseUpdate(ChatRole.Assistant, [approvalRequest])] + : [new AgentResponseUpdate(ChatRole.Assistant, "Done")]; + return ToAsyncEnumerableAsync(streamUpdates, ct); + }); + + var options = new ToolApprovalAgentOptions + { + AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule] + }; + var agent = new ToolApprovalAgent(innerAgent.Object, options); + + // Act — invoke WITHOUT a session. + var updates = new List(); + await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hi")])) + { + updates.Add(update); + } + + // Assert — both inner calls received the same, non-null session. + Assert.Equal(2, callCount); + Assert.Equal("Done", string.Concat(updates.Select(u => u.Text))); + Assert.Equal(2, capturedSessions.Count); + Assert.All(capturedSessions, s => Assert.Same(createdSession, s)); + } + + /// + /// Verify that when no session is supplied, the agent creates exactly one session and threads + /// it to the inner agent, even when no approval re-invocation is needed. + /// + [Fact] + public async Task RunAsync_NoApprovalRequest_NoSession_CreatesSingleSessionAsync() + { + // Arrange + var createdSession = new ChatClientAgentSession(); + var createSessionCallCount = 0; + var capturedSessions = new List(); + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(() => + { + createSessionCallCount++; + return new ValueTask(createdSession); + }); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>( + (_, session, _, _) => capturedSessions.Add(session)) + .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")])); + + var agent = new ToolApprovalAgent(innerAgent.Object, new ToolApprovalAgentOptions + { + AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule] + }); + + // Act + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")]); + + // Assert — a single session was created and threaded to the inner agent. + Assert.Equal("Done", response.Text); + Assert.Equal(1, createSessionCallCount); + Assert.Single(capturedSessions); + Assert.Same(createdSession, capturedSessions[0]); + } + [Fact] public async Task RunAsync_AutoApprovalRule_DoesNotMatchSurfacesToCallerAsync() {