Skip to content
Merged
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
Expand Up @@ -115,6 +115,13 @@ protected override async Task<AgentResponse> 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.
Expand Down Expand Up @@ -158,6 +165,11 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1962,8 +1962,165 @@ [new ChatMessage(ChatRole.User, "Hi")],
}

/// <summary>
/// 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).
/// </summary>
[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<AgentSession?>();
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask<AgentSession>(createdSession));
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, 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));
}

/// <summary>
/// Streaming counterpart of <see cref="RunAsync_AutoApprovalRule_NoSession_CreatesAndThreadsSessionAsync"/>:
/// when no session is supplied, the streaming path also creates one and threads it to the inner
/// agent across auto-approval re-invocations.
/// </summary>
[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<AgentSession?>();
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask<AgentSession>(createdSession));
innerAgent
.Protected()
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns<IEnumerable<ChatMessage>, 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<AgentResponseUpdate>();
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));
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task RunAsync_NoApprovalRequest_NoSession_CreatesSingleSessionAsync()
{
// Arrange
var createdSession = new ChatClientAgentSession();
var createSessionCallCount = 0;
var capturedSessions = new List<AgentSession?>();
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.Returns(() =>
{
createSessionCallCount++;
return new ValueTask<AgentSession>(createdSession);
});
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, 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()
{
Expand Down
Loading