.NET: Add support for background responses (#1501)

* add support for background responses

* Update dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponseUpdate.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix broken link

* fix xml comments and background responses properties override funcitonity

* change ai model provider

* use Run{Streaming}Async overloads that don't require messages

* stop using m: prefix in cref attribute of <see/> element.

* reject input messages provided with continuation token + don't extract messages from message store and context provide if continuation token is provided

* use agent thread for background-responses sample

* require agent thread for background responses

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>

* remove CA1200

* Update dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>

* Update dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponse.cs

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>

* Update dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponse.cs

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>

* address pr review comments

* Update dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Program.cs

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
This commit is contained in:
SergeyMenshykh
2025-10-22 18:43:57 +01:00
committed by GitHub
Unverified
parent 699149c260
commit 1bf520a7c2
17 changed files with 2499 additions and 39 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -175,7 +175,7 @@ Sub-packages are comprised of two parts, the code itself and the dependencies, t
- Subpackage naming should also follow this, so in principle a package name is `<vendor/folder>-<feature/brand>`, so `google-gemini`, `azure-purview`, `microsoft-copilotstudio`, etc. For smaller vendors, where it's less likely to have a multitude of connectors, we can skip the feature/brand part, so `mem0`, `redis`, etc.
- For Microsoft services we will have two vendor folders, `azure` and `microsoft`, where `azure` contains all Azure services, while `microsoft` contains other Microsoft services, such as Copilot Studio Agents.
This setup was discussed at length and the decision is captured in [ADR-0007](../decisions/0007-python-subpackages.md).
This setup was discussed at length and the decision is captured in [ADR-0008](../decisions/0008-python-subpackages.md).
#### Evolving the package structure
For each of the advanced components, we have two reason why we may split them into a folder, with an `__init__.py` and optionally a `_files.py`:
+9 -2
View File
@@ -57,6 +57,7 @@
<Project Path="samples/GettingStarted/Agents/Agent_Step14_Middleware/Agent_Step14_Middleware.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step15_Plugins/Agent_Step15_Plugins.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Agent_Step16_ChatReduction.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Agent_Step17_BackgroundResponses.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/AgentWithOpenAI/">
<File Path="samples/GettingStarted/AgentWithOpenAI/README.md" />
@@ -162,8 +163,14 @@
<Folder Name="/Solution Items/docs/" />
<Folder Name="/Solution Items/docs/decisions/">
<File Path="../docs/decisions/0001-agent-run-response.md" />
<File Path="../docs/decisions/0001-agent-tools.md" />
<File Path="../docs/decisions/0002-agent-opentelemetry-instrumentation.md" />
<File Path="../docs/decisions/0002-agent-tools.md" />
<File Path="../docs/decisions/0003-agent-opentelemetry-instrumentation.md" />
<File Path="../docs/decisions/0004-foundry-sdk-extensions.md" />
<File Path="../docs/decisions/0005-python-naming-conventions.md" />
<File Path="../docs/decisions/0006-userapproval.md" />
<File Path="../docs/decisions/0007-agent-filtering-middleware.md" />
<File Path="../docs/decisions/0008-python-subpackages.md" />
<File Path="../docs/decisions/0009-support-long-running-operations.md" />
<File Path="../docs/decisions/adr-short-template.md" />
<File Path="../docs/decisions/adr-template.md" />
<File Path="../docs/decisions/README.md" />
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use background responses with ChatClientAgent and OpenAI Responses.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker");
// Enable background responses (only supported by OpenAI Responses at this time).
AgentRunOptions options = new() { AllowBackgroundResponses = true };
AgentThread thread = agent.GetNewThread();
// Start the initial run.
AgentRunResponse response = await agent.RunAsync("Tell me a joke about a pirate.", thread, options);
// Poll until the response is complete.
while (response.ContinuationToken is { } token)
{
// Wait before polling again.
await Task.Delay(TimeSpan.FromSeconds(2));
// Continue with the token.
options.ContinuationToken = token;
response = await agent.RunAsync(thread, options);
}
// Display the result.
Console.WriteLine(response.Text);
// Reset options and thread for streaming.
options = new() { AllowBackgroundResponses = true };
thread = agent.GetNewThread();
AgentRunResponseUpdate? lastReceivedUpdate = null;
// Start streaming.
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync("Tell me a joke about a pirate.", thread, options))
{
// Output each update.
Console.Write(update.Text);
// Track last update.
lastReceivedUpdate = update;
// Simulate connection loss after first piece of content received.
if (update.Text.Length > 0)
{
break;
}
}
// Resume from interruption point.
options.ContinuationToken = lastReceivedUpdate?.ContinuationToken;
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(thread, options))
{
// Output each update.
Console.Write(update.Text);
}
@@ -0,0 +1,22 @@
# What This Sample Shows
This sample demonstrates how to use background responses with ChatCompletionAgent and OpenAI Responses for long-running operations. Background responses support:
- **Polling for completion** - Non-streaming APIs can start a background operation and return a continuation token. Poll with the token until the response completes.
- **Resuming after interruption** - Streaming APIs can be interrupted and resumed from the last update using the continuation token.
> **Note:** Background responses are currently only supported by OpenAI Responses.
# Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 8.0 SDK or later
- OpenAI api key
Set the following environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
```
@@ -42,6 +42,7 @@ Before you begin, ensure you have the following prerequisites:
|[Using middleware with an agent](./Agent_Step14_Middleware/)|This sample demonstrates how to use middleware with an agent|
|[Using plugins with an agent](./Agent_Step15_Plugins/)|This sample demonstrates how to use plugins with an agent|
|[Reducing chat history size](./Agent_Step16_ChatReduction/)|This sample demonstrates how to reduce the chat history to constrain its size, where chat history is maintained locally|
|[Background responses](./Agent_Step17_BackgroundResponses/)|This sample demonstrates how to use background responses for long-running operations with polling and resumption support|
## Running the samples from the console
@@ -10,9 +10,6 @@ namespace Microsoft.Agents.AI;
/// </summary>
/// <remarks>
/// <para>
/// This class currently has no options, but may be extended in the future to include additional configuration settings.
/// </para>
/// <para>
/// Implementations of <see cref="AIAgent"/> may provide subclasses of <see cref="AgentRunOptions"/> with additional options specific to that agent type.
/// </para>
/// </remarks>
@@ -33,5 +30,48 @@ public class AgentRunOptions
public AgentRunOptions(AgentRunOptions options)
{
_ = Throw.IfNull(options);
this.ContinuationToken = options.ContinuationToken;
this.AllowBackgroundResponses = options.AllowBackgroundResponses;
}
/// <summary>
/// Gets or sets the continuation token for resuming and getting the result of the agent response identified by this token.
/// </summary>
/// <remarks>
/// This property is used for background responses that can be activated via the <see cref="AllowBackgroundResponses"/>
/// property if the <see cref="AIAgent"/> implementation supports them.
/// Streamed background responses, such as those returned by default by <see cref="AIAgent.RunStreamingAsync(AgentThread?, AgentRunOptions?, System.Threading.CancellationToken)"/>
/// can be resumed if interrupted. This means that a continuation token obtained from the <see cref="AgentRunResponseUpdate.ContinuationToken"/>
/// of an update just before the interruption occurred can be passed to this property to resume the stream from the point of interruption.
/// Non-streamed background responses, such as those returned by <see cref="AIAgent.RunAsync(AgentThread?, AgentRunOptions?, System.Threading.CancellationToken)"/>,
/// can be polled for completion by obtaining the token from the <see cref="AgentRunResponse.ContinuationToken"/> property
/// and passing it via this property on subsequent calls to <see cref="AIAgent.RunAsync(AgentThread?, AgentRunOptions?, System.Threading.CancellationToken)"/>.
/// </remarks>
public object? ContinuationToken { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the background responses are allowed.
/// </summary>
/// <remarks>
/// <para>
/// Background responses allow running long-running operations or tasks asynchronously in the background that can be resumed by streaming APIs
/// and polled for completion by non-streaming APIs.
/// </para>
/// <para>
/// When this property is set to true, non-streaming APIs may start a background operation and return an initial
/// response with a continuation token. Subsequent calls to the same API should be made in a polling manner with
/// the continuation token to get the final result of the operation.
/// </para>
/// <para>
/// When this property is set to true, streaming APIs may also start a background operation and begin streaming
/// response updates until the operation is completed. If the streaming connection is interrupted, the
/// continuation token obtained from the last update that has one should be supplied to a subsequent call to the same streaming API
/// to resume the stream from the point of interruption and continue receiving updates until the operation is completed.
/// </para>
/// <para>
/// This property only takes effect if the implementation it's used with supports background responses.
/// If the implementation does not support background responses, this property will be ignored.
/// </para>
/// </remarks>
public bool? AllowBackgroundResponses { get; set; }
}
@@ -74,6 +74,7 @@ public class AgentRunResponse
this.RawRepresentation = response;
this.ResponseId = response.ResponseId;
this.Usage = response.Usage;
this.ContinuationToken = response.ContinuationToken;
}
/// <summary>
@@ -159,6 +160,23 @@ public class AgentRunResponse
/// </value>
public string? ResponseId { get; set; }
/// <summary>
/// Gets or sets the continuation token for getting the result of a background agent response.
/// </summary>
/// <remarks>
/// <see cref="AIAgent"/> implementations that support background responses will return
/// a continuation token if background responses are allowed in <see cref="AgentRunOptions.AllowBackgroundResponses"/>
/// and the result of the response has not been obtained yet. If the response has completed and the result has been obtained,
/// the token will be <see langword="null"/>.
/// <para>
/// This property should be used in conjunction with <see cref="AgentRunOptions.ContinuationToken"/> to
/// continue to poll for the completion of the response. Pass this token to
/// <see cref="AgentRunOptions.ContinuationToken"/> on subsequent calls to <see cref="AIAgent.RunAsync(AgentThread?, AgentRunOptions?, System.Threading.CancellationToken)"/>
/// to poll for completion.
/// </para>
/// </remarks>
public object? ContinuationToken { get; set; }
/// <summary>
/// Gets or sets the timestamp indicating when this response was created.
/// </summary>
@@ -234,7 +252,7 @@ public class AgentRunResponse
{
extra = new AgentRunResponseUpdate
{
AdditionalProperties = this.AdditionalProperties
AdditionalProperties = this.AdditionalProperties,
};
if (this.Usage is { } usage)
@@ -42,6 +42,7 @@ public static class AgentRunResponseExtensions
RawRepresentation = response,
ResponseId = response.ResponseId,
Usage = response.Usage,
ContinuationToken = response.ContinuationToken,
};
}
@@ -74,6 +75,7 @@ public static class AgentRunResponseExtensions
RawRepresentation = responseUpdate,
ResponseId = responseUpdate.ResponseId,
Role = responseUpdate.Role,
ContinuationToken = responseUpdate.ContinuationToken,
};
}
@@ -78,6 +78,7 @@ public class AgentRunResponseUpdate
this.RawRepresentation = chatResponseUpdate;
this.ResponseId = chatResponseUpdate.ResponseId;
this.Role = chatResponseUpdate.Role;
this.ContinuationToken = chatResponseUpdate.ContinuationToken;
}
/// <summary>Gets or sets the name of the author of the response update.</summary>
@@ -148,6 +149,21 @@ public class AgentRunResponseUpdate
/// <summary>Gets or sets a timestamp for the response update.</summary>
public DateTimeOffset? CreatedAt { get; set; }
/// <summary>
/// Gets or sets the continuation token for resuming the streamed agent response of which this update is a part.
/// </summary>
/// <remarks>
/// <see cref="AIAgent"/> implementations that support background responses will return
/// a continuation token on each update if background responses are allowed in <see cref="AgentRunOptions.AllowBackgroundResponses"/>
/// except for the last update, for which the token will be <see langword="null"/>.
/// <para>
/// This property should be used for stream resumption, where the continuation token of the latest received update should be
/// passed to <see cref="AgentRunOptions.ContinuationToken"/> on subsequent calls to <see cref="AIAgent.RunStreamingAsync(AgentThread?, AgentRunOptions?, System.Threading.CancellationToken)"/>
/// to resume streaming from the point of interruption.
/// </para>
/// </remarks>
public object? ContinuationToken { get; set; }
/// <inheritdoc/>
public override string ToString() => this.Text;
@@ -436,13 +436,13 @@ public sealed partial class ChatClientAgent : AIAgent
// If no agent chat options were provided, return the request chat options as is.
if (this._agentOptions?.ChatOptions is null)
{
return requestChatOptions;
return ApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
}
// If no request chat options were provided, use the agent's chat options clone.
if (requestChatOptions is null)
{
return this._agentOptions?.ChatOptions.Clone();
return ApplyBackgroundResponsesProperties(this._agentOptions?.ChatOptions.Clone(), runOptions);
}
// If both are present, we need to merge them.
@@ -532,7 +532,20 @@ public sealed partial class ChatClientAgent : AIAgent
}
}
return requestChatOptions;
return ApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
static ChatOptions? ApplyBackgroundResponsesProperties(ChatOptions? chatOptions, AgentRunOptions? agentRunOptions)
{
// If any of the background response properties are set in the run options, we should apply both to the chat options.
if (agentRunOptions?.AllowBackgroundResponses is not null || agentRunOptions?.ContinuationToken is not null)
{
chatOptions ??= new ChatOptions();
chatOptions.AllowBackgroundResponses = agentRunOptions.AllowBackgroundResponses;
chatOptions.ContinuationToken = agentRunOptions.ContinuationToken;
}
return chatOptions;
}
}
/// <summary>
@@ -551,50 +564,67 @@ public sealed partial class ChatClientAgent : AIAgent
{
ChatOptions? chatOptions = this.CreateConfiguredChatOptions(runOptions);
// Supplying a thread for background responses is required to prevent inconsistent experience
// for callers if they forget to provide the thread for initial or follow-up runs.
if (chatOptions?.AllowBackgroundResponses is true && thread is null)
{
throw new InvalidOperationException("A thread must be provided when continuing a background response with a continuation token.");
}
thread ??= this.GetNewThread();
if (thread is not ChatClientAgentThread typedThread)
{
throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used.");
}
// Add any existing messages from the thread to the messages to be sent to the chat client.
List<ChatMessage> threadMessages = [];
if (typedThread.MessageStore is not null)
// Supplying messages when continuing a background response is not allowed.
if (chatOptions?.ContinuationToken is not null && inputMessages.Any())
{
threadMessages.AddRange(await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false));
throw new InvalidOperationException("Input messages are not allowed when continuing a background response using a continuation token.");
}
List<ChatMessage> threadMessages = [];
// If we have an AIContextProvider, we should get context from it, and update our
// messages and options with the additional context.
if (typedThread.AIContextProvider is not null)
// Populate the thread messages only if we are not continuing an existing response as it's not allowed
if (chatOptions?.ContinuationToken is null)
{
var invokingContext = new AIContextProvider.InvokingContext(inputMessages);
var aiContext = await typedThread.AIContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
if (aiContext.Messages is { Count: > 0 })
// Add any existing messages from the thread to the messages to be sent to the chat client.
if (typedThread.MessageStore is not null)
{
threadMessages.AddRange(aiContext.Messages);
threadMessages.AddRange(await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false));
}
if (aiContext.Tools is { Count: > 0 })
// If we have an AIContextProvider, we should get context from it, and update our
// messages and options with the additional context.
if (typedThread.AIContextProvider is not null)
{
chatOptions ??= new();
chatOptions.Tools ??= [];
foreach (AITool tool in aiContext.Tools)
var invokingContext = new AIContextProvider.InvokingContext(inputMessages);
var aiContext = await typedThread.AIContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
if (aiContext.Messages is { Count: > 0 })
{
chatOptions.Tools.Add(tool);
threadMessages.AddRange(aiContext.Messages);
}
if (aiContext.Tools is { Count: > 0 })
{
chatOptions ??= new();
chatOptions.Tools ??= [];
foreach (AITool tool in aiContext.Tools)
{
chatOptions.Tools.Add(tool);
}
}
if (aiContext.Instructions is not null)
{
chatOptions ??= new();
chatOptions.Instructions = string.IsNullOrWhiteSpace(chatOptions.Instructions) ? aiContext.Instructions : $"{chatOptions.Instructions}\n{aiContext.Instructions}";
}
}
if (aiContext.Instructions is not null)
{
chatOptions ??= new();
chatOptions.Instructions = string.IsNullOrWhiteSpace(chatOptions.Instructions) ? aiContext.Instructions : $"{chatOptions.Instructions}\n{aiContext.Instructions}";
}
// Add the input messages to the end of thread messages.
threadMessages.AddRange(inputMessages);
}
// Add the input messages to the end of thread messages.
threadMessages.AddRange(inputMessages);
// If a user provided two different thread ids, via the thread object and options, we should throw
// since we don't know which one to use.
if (!string.IsNullOrWhiteSpace(typedThread.ConversationId) && !string.IsNullOrWhiteSpace(chatOptions?.ConversationId) && typedThread.ConversationId != chatOptions!.ConversationId)
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
@@ -13,15 +15,44 @@ public class AgentRunOptionsTests
public void CloningConstructorCopiesProperties()
{
// Arrange
var options = new AgentRunOptions();
var options = new AgentRunOptions
{
ContinuationToken = new object(),
AllowBackgroundResponses = true
};
// Act
var clone = new AgentRunOptions(options);
// Assert
Assert.NotNull(clone);
Assert.Same(options.ContinuationToken, clone.ContinuationToken);
Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses);
}
[Fact]
public void CloningConstructorThrowsIfNull() =>
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new AgentRunOptions(null!));
[Fact]
public void JsonSerializationRoundtrips()
{
// Arrange
var options = new AgentRunOptions
{
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
AllowBackgroundResponses = true
};
// Act
string json = JsonSerializer.Serialize(options, AgentAbstractionsJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<AgentRunOptions>(json, AgentAbstractionsJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), deserialized!.ContinuationToken);
Assert.Equal(options.AllowBackgroundResponses, deserialized.AllowBackgroundResponses);
}
}
@@ -19,10 +19,12 @@ public class AgentRunResponseTests
response = new();
Assert.Empty(response.Messages);
Assert.Empty(response.Text);
Assert.Null(response.ContinuationToken);
response = new((IList<ChatMessage>?)null);
Assert.Empty(response.Messages);
Assert.Empty(response.Text);
Assert.Null(response.ContinuationToken);
Assert.Throws<ArgumentNullException>("message", () => new AgentRunResponse((ChatMessage)null!));
}
@@ -55,6 +57,7 @@ public class AgentRunResponseTests
RawRepresentation = new object(),
ResponseId = "responseId",
Usage = new UsageDetails(),
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
};
AgentRunResponse response = new(chatResponse);
@@ -64,6 +67,7 @@ public class AgentRunResponseTests
Assert.Equal(chatResponse.ResponseId, response.ResponseId);
Assert.Same(chatResponse, response.RawRepresentation as ChatResponse);
Assert.Same(chatResponse.Usage, response.Usage);
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), response.ContinuationToken);
}
[Fact]
@@ -97,6 +101,10 @@ public class AgentRunResponseTests
AdditionalPropertiesDictionary additionalProps = [];
response.AdditionalProperties = additionalProps;
Assert.Same(additionalProps, response.AdditionalProperties);
Assert.Null(response.ContinuationToken);
response.ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), response.ContinuationToken);
}
[Fact]
@@ -110,11 +118,12 @@ public class AgentRunResponseTests
Usage = new UsageDetails(),
RawRepresentation = new(),
AdditionalProperties = new() { ["key"] = "value" },
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
};
string json = JsonSerializer.Serialize(original, TestJsonSerializerContext.Default.AgentRunResponse);
string json = JsonSerializer.Serialize(original, AgentAbstractionsJsonUtilities.DefaultOptions);
AgentRunResponse? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.AgentRunResponse);
AgentRunResponse? result = JsonSerializer.Deserialize<AgentRunResponse>(json, AgentAbstractionsJsonUtilities.DefaultOptions);
Assert.NotNull(result);
Assert.Equal(ChatRole.Assistant, result.Messages.Single().Role);
@@ -130,6 +139,7 @@ public class AgentRunResponseTests
Assert.True(result.AdditionalProperties.TryGetValue("key", out object? value));
Assert.IsType<JsonElement>(value);
Assert.Equal("value", ((JsonElement)value!).GetString());
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), result.ContinuationToken);
}
[Fact]
@@ -23,6 +23,7 @@ public class AgentRunResponseUpdateTests
Assert.Null(update.MessageId);
Assert.Null(update.CreatedAt);
Assert.Equal(string.Empty, update.ToString());
Assert.Null(update.ContinuationToken);
}
[Fact]
@@ -41,6 +42,7 @@ public class AgentRunResponseUpdateTests
RawRepresentation = new object(),
ResponseId = "responseId",
Role = ChatRole.Assistant,
ContinuationToken = new object(),
};
AgentRunResponseUpdate response = new(chatResponseUpdate);
@@ -52,6 +54,7 @@ public class AgentRunResponseUpdateTests
Assert.Same(chatResponseUpdate, response.RawRepresentation as ChatResponseUpdate);
Assert.Equal(chatResponseUpdate.ResponseId, response.ResponseId);
Assert.Equal(chatResponseUpdate.Role, response.Role);
Assert.Same(chatResponseUpdate.ContinuationToken, response.ContinuationToken);
}
[Fact]
@@ -102,6 +105,10 @@ public class AgentRunResponseUpdateTests
Assert.Null(update.CreatedAt);
update.CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero);
Assert.Equal(new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero), update.CreatedAt);
Assert.Null(update.ContinuationToken);
update.ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), update.ContinuationToken);
}
[Fact]
@@ -152,11 +159,12 @@ public class AgentRunResponseUpdateTests
MessageId = "messageid",
CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero),
AdditionalProperties = new() { ["key"] = "value" },
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })
};
string json = JsonSerializer.Serialize(original, TestJsonSerializerContext.Default.AgentRunResponseUpdate);
string json = JsonSerializer.Serialize(original, AgentAbstractionsJsonUtilities.DefaultOptions);
AgentRunResponseUpdate? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.AgentRunResponseUpdate);
AgentRunResponseUpdate? result = JsonSerializer.Deserialize<AgentRunResponseUpdate>(json, AgentAbstractionsJsonUtilities.DefaultOptions);
Assert.NotNull(result);
Assert.Equal(5, result.Contents.Count);
@@ -187,5 +195,8 @@ public class AgentRunResponseUpdateTests
Assert.True(result.AdditionalProperties.TryGetValue("key", out object? value));
Assert.IsType<JsonElement>(value);
Assert.Equal("value", ((JsonElement)value!).GetString());
Assert.NotNull(result.ContinuationToken);
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), result.ContinuationToken);
}
}
@@ -1951,6 +1951,499 @@ public partial class ChatClientAgentTests
#endregion
#region Background Responses Tests
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RunAsyncPropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
{
// Arrange
object continuationToken = new();
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ContinuationToken = null });
AgentRunOptions agentRunOptions;
if (providePropsViaChatOptions)
{
ChatOptions chatOptions = new()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken
};
agentRunOptions = new ChatClientAgentRunOptions(chatOptions);
}
else
{
agentRunOptions = new AgentRunOptions()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken
};
}
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentThread thread = new();
// Act
await agent.RunAsync(thread, options: agentRunOptions);
// Assert
Assert.NotNull(capturedChatOptions);
Assert.True(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken, capturedChatOptions.ContinuationToken);
}
[Fact]
public async Task RunAsyncPrioritizesBackgroundResponsesPropertiesFromAgentRunOptionsOverOnesFromChatOptionsAsync()
{
// Arrange
object continuationToken1 = new();
object continuationToken2 = new();
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ContinuationToken = null });
ChatOptions chatOptions = new()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken1
};
ChatClientAgentRunOptions agentRunOptions = new(chatOptions)
{
AllowBackgroundResponses = false,
ContinuationToken = continuationToken2
};
ChatClientAgent agent = new(mockChatClient.Object);
// Act
await agent.RunAsync(options: agentRunOptions);
// Assert
Assert.NotNull(capturedChatOptions);
Assert.False(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken2, capturedChatOptions.ContinuationToken);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RunStreamingAsyncPropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh"),
new ChatResponseUpdate(role: ChatRole.Assistant, content: "at?"),
];
object continuationToken = new();
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
.Returns(ToAsyncEnumerableAsync(returnUpdates));
AgentRunOptions agentRunOptions;
if (providePropsViaChatOptions)
{
ChatOptions chatOptions = new()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken
};
agentRunOptions = new ChatClientAgentRunOptions(chatOptions);
}
else
{
agentRunOptions = new AgentRunOptions()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken
};
}
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentThread thread = new();
// Act
await foreach (var _ in agent.RunStreamingAsync(thread, options: agentRunOptions))
{
}
// Assert
Assert.NotNull(capturedChatOptions);
Assert.True(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken, capturedChatOptions.ContinuationToken);
}
[Fact]
public async Task RunStreamingAsyncPrioritizesBackgroundResponsesPropertiesFromAgentRunOptionsOverOnesFromChatOptionsAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh"),
];
object continuationToken1 = new();
object continuationToken2 = new();
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatOptions chatOptions = new()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken1
};
ChatClientAgentRunOptions agentRunOptions = new(chatOptions)
{
AllowBackgroundResponses = false,
ContinuationToken = continuationToken2
};
ChatClientAgent agent = new(mockChatClient.Object);
// Act
await foreach (var _ in agent.RunStreamingAsync(options: agentRunOptions))
{
}
// Assert
Assert.NotNull(capturedChatOptions);
Assert.False(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken2, capturedChatOptions.ContinuationToken);
}
[Fact]
public async Task RunAsyncPropagatesContinuationTokenFromChatResponseToAgentRunResponseAsync()
{
// Arrange
object continuationToken = new();
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "partial")]) { ContinuationToken = continuationToken });
ChatClientAgent agent = new(mockChatClient.Object);
var runOptions = new ChatClientAgentRunOptions(new ChatOptions { AllowBackgroundResponses = true });
ChatClientAgentThread thread = new();
// Act
var response = await agent.RunAsync([new(ChatRole.User, "hi")], thread, options: runOptions);
// Assert
Assert.Same(continuationToken, response.ContinuationToken);
}
[Fact]
public async Task RunStreamingAsyncPropagatesContinuationTokensFromUpdatesAsync()
{
// Arrange
object token1 = new();
ChatResponseUpdate[] expectedUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "pa") { ContinuationToken = token1 },
new ChatResponseUpdate(ChatRole.Assistant, "rt") { ContinuationToken = null } // terminal
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(expectedUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentThread thread = new();
// Act
var actualUpdates = new List<AgentRunResponseUpdate>();
await foreach (var u in agent.RunStreamingAsync([new(ChatRole.User, "hi")], thread, options: new ChatClientAgentRunOptions(new ChatOptions { AllowBackgroundResponses = true })))
{
actualUpdates.Add(u);
}
// Assert
Assert.Equal(2, actualUpdates.Count);
Assert.Same(token1, actualUpdates[0].ContinuationToken);
Assert.Null(actualUpdates[1].ContinuationToken); // last update has null token
}
[Fact]
public async Task RunAsyncThrowsWhenMessagesProvidedWithContinuationTokenAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { ContinuationToken = new() };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(inputMessages, options: runOptions));
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunStreamingAsyncThrowsWhenMessagesProvidedWithContinuationTokenAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { ContinuationToken = new() };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync(inputMessages, options: runOptions))
{
// Should not reach here
}
});
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunAsyncSkipsThreadMessagePopulationWithContinuationTokenAsync()
{
// Arrange
List<ChatMessage> capturedMessages = [];
// Create a mock message store that would normally provide messages
var mockMessageStore = new Mock<ChatMessageStore>();
mockMessageStore
.Setup(ms => ms.GetMessagesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([new(ChatRole.User, "Message from message store")]);
// Create a mock AI context provider that would normally provide context
var mockContextProvider = new Mock<AIContextProvider>();
mockContextProvider
.Setup(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AIContext
{
Messages = [new(ChatRole.System, "Message from AI context")],
Instructions = "context instructions"
});
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedMessages.AddRange(msgs))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "continued response")]));
ChatClientAgent agent = new(mockChatClient.Object);
// Create a thread with both message store and AI context provider
ChatClientAgentThread thread = new()
{
MessageStore = mockMessageStore.Object,
AIContextProvider = mockContextProvider.Object
};
AgentRunOptions runOptions = new() { ContinuationToken = new() };
// Act
await agent.RunAsync([], thread, options: runOptions);
// Assert
// With continuation token, thread message population should be skipped
Assert.Empty(capturedMessages);
// Verify that message store was never called due to continuation token
mockMessageStore.Verify(
ms => ms.GetMessagesAsync(It.IsAny<CancellationToken>()),
Times.Never);
// Verify that AI context provider was never called due to continuation token
mockContextProvider.Verify(
p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunStreamingAsyncSkipsThreadMessagePopulationWithContinuationTokenAsync()
{
// Arrange
List<ChatMessage> capturedMessages = [];
// Create a mock message store that would normally provide messages
var mockMessageStore = new Mock<ChatMessageStore>();
mockMessageStore
.Setup(ms => ms.GetMessagesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([new(ChatRole.User, "Message from message store")]);
// Create a mock AI context provider that would normally provide context
var mockContextProvider = new Mock<AIContextProvider>();
mockContextProvider
.Setup(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AIContext
{
Messages = [new(ChatRole.System, "Message from AI context")],
Instructions = "context instructions"
});
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedMessages.AddRange(msgs))
.Returns(ToAsyncEnumerableAsync([new ChatResponseUpdate(role: ChatRole.Assistant, content: "continued response")]));
ChatClientAgent agent = new(mockChatClient.Object);
// Create a thread with both message store and AI context provider
ChatClientAgentThread thread = new()
{
MessageStore = mockMessageStore.Object,
AIContextProvider = mockContextProvider.Object
};
AgentRunOptions runOptions = new() { ContinuationToken = new() };
// Act
await agent.RunStreamingAsync([], thread, options: runOptions).ToListAsync();
// Assert
// With continuation token, thread message population should be skipped
Assert.Empty(capturedMessages);
// Verify that message store was never called due to continuation token
mockMessageStore.Verify(
ms => ms.GetMessagesAsync(It.IsAny<CancellationToken>()),
Times.Never);
// Verify that AI context provider was never called due to continuation token
mockContextProvider.Verify(
p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunAsyncThrowsWhenNoThreadProvideForBackgroundResponsesAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { AllowBackgroundResponses = true };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(inputMessages, options: runOptions));
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunStreamingAsyncThrowsWhenNoThreadProvideForBackgroundResponsesAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { AllowBackgroundResponses = true };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync(inputMessages, options: runOptions))
{
// Should not reach here
}
});
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
#endregion
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> values)
{
await Task.Yield();