Compare commits

..
Author SHA1 Message Date
SergeyMenshykhandGitHub 394e9c1692 .NET: Improve skill name validation: reject consecutive hyphens and enforce directory name match (#4526)
* improve skill validation

* address pr review comments
2026-03-06 17:29:53 +00:00
CopilotGitHubCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>rogerbarreto
7e98b0cd29 .NET: Update HostedAgents samples to Azure.AI.AgentServer.AgentFramework 1.0.0-beta.9 and MEAI 10.3.0 (#4477)
* Initial plan

* Update HostedAgents samples to Azure.AI.AgentServer.AgentFramework 1.0.0-beta.9 and MEAI 10.3.0

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

* Fix HostedAgents samples for Microsoft.Agents.AI 1.0.0-rc2 API changes

- Rename CreateAIAgent -> AsAIAgent (AgentThreadAndHITL, AgentWithHostedMCP, AgentWithTextSearchRag)
- Rename AsAgent -> AsAIAgent (AgentsInWorkflows)
- Replace AIContextProviderFactory with AIContextProviders and simplified TextSearchProvider ctor (AgentWithTextSearchRag)
- Update Microsoft.Agents.AI.OpenAI to 1.0.0-rc2 (AgentThreadAndHITL, AgentWithTextSearchRag, AgentWithTools)
- Update Microsoft.Agents.AI.Workflows to 1.0.0-rc2 (AgentsInWorkflows)
- Add Microsoft.Agents.AI 1.0.0-rc2 reference (AgentWithHostedMCP)

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

* Update HostedAgents samples for beta.9 API changes and add missing projects to slnx

- Use DefaultAzureCredential consistently across all samples
- Add AgentThreadAndHITL, AgentWithLocalTools, AgentWithTools to slnx
- Apply dotnet format

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

* Remove unnecessary Microsoft.Agents.AI.* package references (transitive from AgentFramework)

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

* Add DefaultAzureCredential production warning comments to all HostedAgents samples

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

* Update HostedAgents READMEs to reflect DefaultAzureCredential usage

Replace AzureCliCredential references with DefaultAzureCredential in all
HostedAgents README files to match the actual sample code.

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

* Replace Microsoft.Extensions.AI.OpenAI with Microsoft.Agents.AI.OpenAI and remove AsIChatClient()

Swap package references from Microsoft.Extensions.AI.OpenAI to
Microsoft.Agents.AI.OpenAI across all 6 HostedAgents samples. This enables
using the AsAIAgent() extension directly on ChatClient/ResponsesClient
(from OpenAI.Chat/OpenAI.Responses namespaces), removing the intermediate
AsIChatClient() call in 3 samples where it was unnecessary.

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

* Use explicit types and AsAIAgent() extensions across all HostedAgents samples

Replace var with explicit types for clarity in all 6 samples. Replace
new ChatClientAgent() constructor calls with chatClient.AsAIAgent()
extension method in AgentWithLocalTools and AgentsInWorkflows.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-06 12:15:10 +00:00
f7e4143c61 .NET: Fix filter combine logic for ChatHistoryMemoryProvider (#4501)
* Fix filter combine logic for ChatHistoryMemoryProvider

* Replace var with explicit types in filter building code and test

Address PR review nit: use explicit types instead of var for better
readability in the filter-building logic and the new combined filter
compilation test.

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

* Fix style issues

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-06 11:59:50 +00:00
westeyandGitHub d8d6ac1c59 Add ServiceLifetime support for Hosting DI registration (#4476) 2026-03-06 09:39:33 +00:00
4bd5469798 Python: Improve ag-ui tests and coverage (#4442)
* Improve ag-ui tests and coverage

* fix tests paths

* Fixes

* Improve AG-UI test robustness and correctness

- Map toolName → tool_call_name in SSE helpers for TOOL_CALL_START events
- Fail loudly on malformed SSE JSON in parse_sse_response() instead of silently dropping
- Detect duplicate TOOL_CALL_START/TOOL_CALL_END in assert_tool_calls_balanced()
- Remove fragile source line reference from test docstring
- Add found guard in test_client_tool_sets_additional_properties to prevent vacuous pass

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-06 07:06:56 +00:00
CopilotGitHubCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>markwallace-microsofteavanvalkenburgTaoChenOSUBen Thomas
1ac68f65bf Python: Fix RedisContextProvider for redisvl 0.14.0 by using AggregateHybridQuery (#3954)
* Initial plan

* Fix: Replace alpha with linear_alpha in HybridQuery for redisvl 0.14.0 compatibility

Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>

* Address code review: Improve test readability and add explanatory comment

Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>

* Add CHANGELOG entry for redisvl 0.14.0 compatibility fix

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Use AggregateHybridQuery instead of HybridQuery for backward compatibility

Replace HybridQuery with AggregateHybridQuery to preserve existing functionality that works with older Redis versions. The new HybridQuery in redisvl 0.14.0 requires Redis 8.4.0+ and uses a different API, while AggregateHybridQuery maintains compatibility with the original implementation.

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Fix test to use linear_alpha parameter matching _redis_search implementation

The test was passing alpha as a keyword argument to _redis_search(), but the
method uses linear_alpha to match the redisvl 0.14.0 AggregateHybridQuery API.

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

* Fix pyright error: use alpha parameter matching AggregateHybridQuery API

AggregateHybridQuery expects 'alpha', not 'linear_alpha'. Updated the
_redis_search method parameter and the test accordingly.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>
Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-06 01:34:35 +00:00
8664d19285 Python: Propagated MCP isError flag through function middleware pipeline (#4511)
* Propagated MCP isError flag through function middleware pipeline

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

* Small update

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

* Fix CI

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-05 23:16:19 +00:00
ce7b5b17c1 Python: Fix as_agent() not defaulting name/description from client properties (#4484)
* Fix as_agent() not defaulting name/description from client properties

AzureAIClient.as_agent() and AzureAIAgentClient.as_agent() now fall back
to self.agent_name and self.agent_description when name/description are
not explicitly passed. This ensures Agent.name is populated for
telemetry spans without requiring callers to repeat the name.

Fixes #4471

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

* Address review: use is None checks instead of truthiness

Switch from name or self.agent_name to explicit is None checks so
that callers can intentionally pass empty strings without them being
replaced by client defaults. Added edge-case tests for empty strings.

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

* Update docstrings to document name/description defaulting behavior

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-05 20:12:11 +00:00
99 changed files with 5789 additions and 3234 deletions
+3 -1
View File
@@ -56,7 +56,6 @@
<Project Path="samples/02-agents/Agents/Agent_Step15_DeepResearch/Agent_Step15_DeepResearch.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step16_Declarative/Agent_Step16_Declarative.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
@@ -285,8 +284,11 @@
</Folder>
<Folder Name="/Samples/05-end-to-end/HostedAgents/">
<Project Path="samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj" />
</Folder>
@@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -1,120 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a ChatHistoryCompactionPipeline as the ChatReducer for an agent's
// in-memory chat history. The pipeline chains multiple compaction strategies from gentle to aggressive:
// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries
// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans
// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns
// 4. TruncationCompactionStrategy - Emergency token-budget backstop
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
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";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Create a chat client for the agent and a separate one for the summarization strategy.
// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization.
IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
// Define a tool the agent can use, so we can see tool-result compaction in action.
[Description("Look up the current price of a product by name.")]
static string LookupPrice([Description("The product name to look up.")] string productName) =>
productName.ToUpperInvariant() switch
{
"LAPTOP" => "The laptop costs $999.99.",
"KEYBOARD" => "The keyboard costs $79.99.",
"MOUSE" => "The mouse costs $29.99.",
_ => $"Sorry, I don't have pricing for '{productName}'."
};
// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
const int MaxTokens = 512;
const int MaxTurns = 4;
ChatHistoryCompactionPipeline compactionPipeline =
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2),
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
new SummarizationCompactionStrategy(summarizerChatClient, MaxTokens, preserveRecentGroups: 2),
// 3. Aggressive: keep only the last N user turns and their responses
new SlidingWindowCompactionStrategy(MaxTurns),
// 4. Emergency: drop oldest groups until under the token budget
new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1));
// TODO: PRECONFIGURED PIPELINE
////Create(
//// Approach.Balanced,
//// Size.Compact,
//// summarizerChatClient);
// Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline.
AIAgent agent =
agentChatClient.AsAIAgent(
new ChatClientAgentOptions
{
Name = "ShoppingAssistant",
ChatOptions = new()
{
Instructions =
"""
You are a helpful, but long winded, shopping assistant.
Help the user look up prices and compare products.
When responding, Be sure to be extra descriptive and use as
many words as possible without sounding ridiculous.
""",
Tools = [AIFunctionFactory.Create(LookupPrice)],
},
ChatHistoryProvider = new InMemoryChatHistoryProvider(new() { ChatReducer = compactionPipeline }),
});
AgentSession session = await agent.CreateSessionAsync();
// Helper to print chat history size
void PrintChatHistory()
{
if (session.TryGetInMemoryChatHistory(out var history))
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"\n[Messages: x{history.Count}]\n");
Console.ResetColor();
}
}
// Run a multi-turn conversation with tool calls to exercise the pipeline.
string[] prompts =
[
"What's the price of a laptop?",
"How about a keyboard?",
"And a mouse?",
"Which product is the cheapest?",
"Can you compare the laptop and the keyboard for me?",
"What was the first product I asked about?",
"Thank you!",
];
foreach (string prompt in prompts)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[User] ");
Console.ResetColor();
Console.WriteLine(prompt);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[Agent] ");
Console.ResetColor();
Console.WriteLine(await agent.RunAsync(prompt, session));
PrintChatHistory();
}
@@ -44,7 +44,6 @@ Before you begin, ensure you have the following prerequisites:
|[Deep research with an agent](./Agent_Step15_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics|
|[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
## Running the samples from the console
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -36,11 +36,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -11,9 +11,10 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
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";
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
[Description("Get the weather for a given location.")]
static string GetWeather([Description("The location to get the weather for.")] string location)
@@ -22,17 +23,19 @@ static string GetWeather([Description("The location to get the weather for.")] s
// Create the chat client and agent.
// Note: ApprovalRequiredAIFunction wraps the tool to require user approval before invocation.
// User should reply with 'approve' or 'reject' when prompted.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
#pragma warning disable MEAI001 // Type is for evaluation purposes only
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsIChatClient()
.CreateAIAgent(
.AsAIAgent(
instructions: "You are a helpful assistant",
tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))]
);
#pragma warning restore MEAI001
var threadRepository = new InMemoryAgentThreadRepository(agent);
InMemoryAgentThreadRepository threadRepository = new(agent);
await agent.RunAIAgentAsync(telemetrySourceName: "Agents", threadRepository: threadRepository);
@@ -35,10 +35,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.6" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -9,9 +9,10 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
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";
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// Create an MCP tool that can be called without approval.
AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAddress: "https://learn.microsoft.com/api/mcp")
@@ -28,8 +29,7 @@ AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetResponsesClient(deploymentName)
.AsIChatClient()
.CreateAIAgent(
.AsAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
name: "MicrosoftLearnAgent",
tools: [mcpTool]);
@@ -18,7 +18,7 @@ Before running this sample, ensure you have:
2. A deployment of a chat model (e.g., gpt-4o-mini)
3. Azure CLI installed and authenticated
**Note**: This sample uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource.
**Note**: This sample uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, make sure you're logged in with `az login` and have access to the Azure OpenAI resource.
## Environment Variables
@@ -36,11 +36,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -15,21 +15,21 @@ using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
string deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
Console.WriteLine($"Project Endpoint: {endpoint}");
Console.WriteLine($"Model Deployment: {deploymentName}");
var seattleHotels = new[]
{
Hotel[] seattleHotels =
[
new Hotel("Contoso Suites", 189, 4.5, "Downtown"),
new Hotel("Fabrikam Residences", 159, 4.2, "Pike Place Market"),
new Hotel("Alpine Ski House", 249, 4.7, "Seattle Center"),
new Hotel("Margie's Travel Lodge", 219, 4.4, "Waterfront"),
new Hotel("Northwind Inn", 139, 4.0, "Capitol Hill"),
new Hotel("Relecloud Hotel", 99, 3.8, "University District"),
};
];
[Description("Get available hotels in Seattle for the specified dates. This simulates a call to a hotel availability API.")]
string GetAvailableHotels(
@@ -54,21 +54,21 @@ string GetAvailableHotels(
return "Error: Check-out date must be after check-in date.";
}
var nights = (checkOut - checkIn).Days;
var availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
int nights = (checkOut - checkIn).Days;
List<Hotel> availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
if (availableHotels.Count == 0)
{
return $"No hotels found in Seattle within your budget of ${maxPrice}/night.";
}
var result = new StringBuilder();
StringBuilder result = new();
result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):");
result.AppendLine();
foreach (var hotel in availableHotels)
foreach (Hotel hotel in availableHotels)
{
var totalCost = hotel.PricePerNight * nights;
int totalCost = hotel.PricePerNight * nights;
result.AppendLine($"**{hotel.Name}**");
result.AppendLine($" Location: {hotel.Location}");
result.AppendLine($" Rating: {hotel.Rating}/5");
@@ -84,7 +84,10 @@ string GetAvailableHotels(
}
}
var credential = new AzureCliCredential();
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
DefaultAzureCredential credential = new();
AIProjectClient projectClient = new(new Uri(endpoint), credential);
ClientConnection connection = projectClient.GetConnection(typeof(AzureOpenAIClient).FullName!);
@@ -96,14 +99,14 @@ if (!connection.TryGetLocatorAsUri(out Uri? openAiEndpoint) || openAiEndpoint is
openAiEndpoint = new Uri($"https://{openAiEndpoint.Host}");
Console.WriteLine($"OpenAI Endpoint: {openAiEndpoint}");
var chatClient = new AzureOpenAIClient(openAiEndpoint, credential)
IChatClient chatClient = new AzureOpenAIClient(openAiEndpoint, credential)
.GetChatClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false)
.Build();
var agent = new ChatClientAgent(chatClient,
AIAgent agent = chatClient.AsAIAgent(
name: "SeattleHotelAgent",
instructions: """
You are a helpful travel assistant specializing in finding hotels in Seattle, Washington.
@@ -35,11 +35,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -11,8 +11,8 @@ using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
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";
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
TextSearchProviderOptions textSearchOptions = new()
{
@@ -28,13 +28,13 @@ AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.CreateAIAgent(new ChatClientAgentOptions
.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
},
AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
AIContextProviders = [new TextSearchProvider(MockSearchAsync, textSearchOptions)]
});
await agent.RunAIAgentAsync();
@@ -35,11 +35,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -9,13 +9,16 @@ using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var openAiEndpoint = 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";
var toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set.");
string openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
string toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set.");
var credential = new AzureCliCredential();
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
DefaultAzureCredential credential = new();
var chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
IChatClient chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
.GetChatClient(deploymentName)
.AsIChatClient()
.AsBuilder()
@@ -23,7 +26,7 @@ var chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
.UseOpenTelemetry(sourceName: "Agents", configure: (cfg) => cfg.EnableSensitiveData = true)
.Build();
var agent = new ChatClientAgent(chatClient,
AIAgent agent = chatClient.AsAIAgent(
name: "AgentWithTools",
instructions: @"You are a helpful assistant with access to tools for fetching Microsoft documentation.
@@ -6,7 +6,7 @@ Key features:
- Configuring Foundry tools using `UseFoundryTools` with MCP and code interpreter
- Connecting to an external MCP tool via a Foundry project connection
- Using `AzureCliCredential` for Azure authentication
- Using `DefaultAzureCredential` for Azure authentication
- OpenTelemetry instrumentation for both the chat client and agent
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
@@ -36,7 +36,7 @@ $env:MCP_TOOL_CONNECTION_ID="SampleMCPTool"
## How It Works
1. An `AzureOpenAIClient` is created with `AzureCliCredential` and used to get a chat client
1. An `AzureOpenAIClient` is created with `DefaultAzureCredential` and used to get a chat client
2. The chat client is wrapped with `UseFoundryTools` which registers two Foundry tool types:
- **MCP connection**: Connects to an external MCP server (Microsoft Learn) via the project connection name, providing documentation fetch and search capabilities
- **Code interpreter**: Allows the agent to execute code snippets when needed
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -35,11 +35,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -12,8 +12,8 @@ using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
// Set up the Azure OpenAI client
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";
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
@@ -32,9 +32,9 @@ AIAgent agent = new WorkflowBuilder(frenchAgent)
.AddEdge(frenchAgent, spanishAgent)
.AddEdge(spanishAgent, englishAgent)
.Build()
.AsAgent();
.AsAIAgent();
await agent.RunAIAgentAsync();
static ChatClientAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
new(chatClient, $"You are a translation assistant that translates the provided text to {targetLanguage}.");
static AIAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
chatClient.AsAIAgent($"You are a translation assistant that translates the provided text to {targetLanguage}.");
@@ -19,7 +19,7 @@ Before you begin, ensure you have the following prerequisites:
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
**Note**: This demo uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
Set the following environment variables:
@@ -25,7 +25,7 @@ Before running any sample, ensure you have:
### Authenticate with Azure CLI
All samples use `AzureCliCredential` for authentication. Make sure you're logged in:
All samples use `DefaultAzureCredential` for authentication, which automatically probes multiple credential sources (environment variables, managed identity, Azure CLI, etc.). For local development, the simplest approach is to authenticate via Azure CLI:
```powershell
az login
@@ -1,102 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
public partial class ChatHistoryCompactionPipeline
{
/// <summary>
/// %%% COMMENT
/// </summary>
public enum Size
{
/// <summary>
/// %%% COMMENT
/// </summary>
Compact,
/// <summary>
/// %%% COMMENT
/// </summary>
Adequate,
/// <summary>
/// %%% COMMENT
/// </summary>
Accomodating,
}
/// <summary>
/// %%% COMMENT
/// </summary>
public enum Approach
{
/// <summary>
/// %%% COMMENT
/// </summary>
Aggressive,
/// <summary>
/// %%% COMMENT
/// </summary>
Balanced,
/// <summary>
/// %%% COMMENT
/// </summary>
Gentle,
}
/// <summary>
/// %%% COMMENT
/// </summary>
/// <param name="approach"></param>
/// <param name="size"></param>
/// <param name="chatClient"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public static ChatHistoryCompactionPipeline Create(Approach approach, Size size, IChatClient chatClient) =>
approach switch
{
Approach.Aggressive => CreateAgressive(size, chatClient),
Approach.Balanced => CreateBalanced(size),
Approach.Gentle => CreateGentle(size),
_ => throw new NotImplementedException(), // %%% EXCEPTION
};
private static ChatHistoryCompactionPipeline CreateAgressive(Size size, IChatClient chatClient) =>
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2),
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
new SummarizationCompactionStrategy(chatClient, MaxTokens(size), preserveRecentGroups: 2),
// 3. Aggressive: keep only the last N user turns and their responses
new SlidingWindowCompactionStrategy(MaxTurns(size)),
// 4. Emergency: drop oldest groups until under the token budget
new TruncationCompactionStrategy(MaxTokens(size), preserveRecentGroups: 1));
private static ChatHistoryCompactionPipeline CreateBalanced(Size size) =>
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2),
// 2. Aggressive: keep only the last N user turns and their responses
new SlidingWindowCompactionStrategy(MaxTurns(size)));
private static ChatHistoryCompactionPipeline CreateGentle(Size size) =>
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2));
private static int MaxTokens(Size size) =>
size switch
{
Size.Compact => 500,
Size.Adequate => 1000,
Size.Accomodating => 2000,
_ => throw new NotImplementedException(), // %%% EXCEPTION
};
private static int MaxTurns(Size size) =>
size switch
{
Size.Compact => 10,
Size.Adequate => 50,
Size.Accomodating => 100,
_ => throw new NotImplementedException(), // %%% EXCEPTION
};
}
@@ -1,114 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Executes a chain of <see cref="ChatHistoryCompactionStrategy"/> instances in order
/// against a mutable message list.
/// </summary>
/// <remarks>
/// <para>
/// Each strategy's trigger is evaluated against the metrics <em>as they stand after prior strategies</em>,
/// so earlier strategies can bring the conversation within thresholds that cause later strategies to skip.
/// </para>
/// <para>
/// The pipeline is fully standalone — it can be used without any agent, session, or context provider.
/// It also implements <see cref="IChatReducer"/> so it can be used directly anywhere a reducer is
/// accepted (e.g., <see cref="InMemoryChatHistoryProviderOptions.ChatReducer"/>).
/// </para>
/// </remarks>
public partial class ChatHistoryCompactionPipeline : IChatReducer
{
private readonly ChatHistoryCompactionStrategy[] _strategies;
private readonly IChatHistoryMetricsCalculator _metricsCalculator;
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryCompactionPipeline"/> class.
/// </summary>
/// <param name="strategies">The ordered list of compaction strategies to execute.</param>
/// <remarks>
/// By default, <see cref="DefaultChatHistoryMetricsCalculator"/> is used.
/// </remarks>
public ChatHistoryCompactionPipeline(
params IEnumerable<ChatHistoryCompactionStrategy> strategies)
: this(metricsCalculator: null, strategies) { }
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryCompactionPipeline"/> class.
/// </summary>
/// <param name="metricsCalculator">
/// An optional metrics calculator. When <see langword="null"/>, a
/// <see cref="DefaultChatHistoryMetricsCalculator"/> is used.
/// </param>
/// <param name="strategies">The ordered list of compaction strategies to execute.</param>
public ChatHistoryCompactionPipeline(
IChatHistoryMetricsCalculator? metricsCalculator,
params IEnumerable<ChatHistoryCompactionStrategy> strategies)
{
this._strategies = [.. Throw.IfNull(strategies)];
this._metricsCalculator = metricsCalculator ?? DefaultChatHistoryMetricsCalculator.Instance;
}
/// <summary>
/// Reduces the given messages by running all strategies in sequence.
/// </summary>
/// <param name="messages">The messages to reduce.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>The reduced set of messages.</returns>
public virtual async Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
List<ChatMessage> messageBuffer = messages is List<ChatMessage> messageList ? messageList : [.. messages];
await this.CompactAsync(messageBuffer, cancellationToken).ConfigureAwait(false);
return messageBuffer;
}
/// <summary>
/// Run all strategies in sequence against the given messages.
/// </summary>
/// <param name="messages">The mutable message list to compact.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="CompactionPipelineResult"/> with aggregate and per-strategy metrics.</returns>
public async ValueTask<CompactionPipelineResult> CompactAsync(
List<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
Throw.IfNull(messages);
ChatHistoryMetric overallBefore = this._metricsCalculator.Calculate(messages);
Debug.WriteLine($"COMPACTION: BEGIN x{overallBefore.MessageCount}/#{overallBefore.UserTurnCount} ({overallBefore.TokenCount} tokens)");
List<CompactionResult> compactionResults = new(this._strategies.Length);
Stopwatch timer = new();
TimeSpan startTime = TimeSpan.Zero;
ChatHistoryMetric overallAfter = overallBefore;
ChatHistoryMetric currentBefore = overallBefore;
foreach (ChatHistoryCompactionStrategy strategy in this._strategies)
{
// %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} START");
timer.Start();
ChatHistoryCompactionStrategy.s_currentMetrics.Value = currentBefore;
CompactionResult strategyResult = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false);
timer.Stop();
TimeSpan elapsedTime = timer.Elapsed - startTime;
// %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} FINISH [{elapsedTime}]");
compactionResults.Add(strategyResult);
overallAfter = currentBefore = strategyResult.After;
}
Debug.WriteLineIf(overallBefore.TokenCount != overallAfter.TokenCount, $"COMPACTION: TOTAL [{timer.Elapsed}] {overallBefore.TokenCount} => {overallAfter.TokenCount} tokens");
return new(overallBefore, overallAfter, compactionResults);
}
}
@@ -1,120 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A named compaction strategy with an optional conditional trigger that delegates
/// actual message reduction to an <see cref="IChatReducer"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each strategy wraps an <see cref="IChatReducer"/> that performs the actual compaction,
/// while the strategy adds:
/// <list type="bullet">
/// <item><description>A conditional trigger via <see cref="ShouldCompact"/> that decides whether compaction runs.</description></item>
/// <item><description>Before/after <see cref="ChatHistoryMetric"/> reporting via <see cref="CompactionResult"/>.</description></item>
/// </list>
/// </para>
/// <para>
/// For simple cases, construct a <see cref="ChatHistoryCompactionStrategy"/> directly with any
/// <see cref="IChatReducer"/>. For custom trigger logic, subclass and override <see cref="ShouldCompact"/>.
/// </para>
/// <para>
/// Reducers <b>must</b> preserve atomic message groups: an assistant message containing
/// tool calls and its corresponding tool result messages must be kept or removed together.
/// Use <see cref="DefaultChatHistoryMetricsCalculator"/> to identify these groups when authoring custom reducers.
/// </para>
/// </remarks>
public abstract class ChatHistoryCompactionStrategy
{
internal static readonly AsyncLocal<ChatHistoryMetric> s_currentMetrics = new();
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryCompactionStrategy"/> class.
/// </summary>
/// <param name="reducer">The <see cref="IChatReducer"/> that performs the actual message compaction.</param>
protected ChatHistoryCompactionStrategy(IChatReducer reducer)
{
this.Reducer = Throw.IfNull(reducer);
}
/// <summary>
/// Exposes the current <see cref="ChatHistoryMetric"/> for the executing strategy, allowing <see cref="Reducer"/> to make informed decisions.
/// </summary>
protected static ChatHistoryMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}.");
/// <summary>
/// Gets the <see cref="IChatReducer"/> that performs the actual message compaction.
/// </summary>
public IChatReducer Reducer { get; }
/// <summary>
/// Gets the display name of this strategy, used for logging and diagnostics.
/// </summary>
/// <remarks>
/// The default implementation returns the type name of the underlying <see cref="IChatReducer"/>.
/// </remarks>
public virtual string Name => this.Reducer.GetType().Name;
/// <summary>
/// Evaluates whether this strategy should execute given the current conversation metrics.
/// </summary>
/// <param name="metrics">The current conversation metrics.</param>
/// <returns>
/// <see langword="true"/> to proceed with compaction; <see langword="false"/> to skip.
/// </returns>
protected abstract bool ShouldCompact(ChatHistoryMetric metrics);
/// <summary>
/// Execute this strategy: check the trigger, delegate to the <see cref="IChatReducer"/>, and report metrics.
/// </summary>
/// <param name="history">The mutable message list to compact.</param>
/// <param name="metricsCalculator">The calculator to use for metric snapshots.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="CompactionResult"/> reporting the outcome.</returns>
internal async ValueTask<CompactionResult> CompactAsync(
List<ChatMessage> history,
IChatHistoryMetricsCalculator metricsCalculator,
CancellationToken cancellationToken = default)
{
Throw.IfNull(metricsCalculator);
Throw.IfNull(history);
ChatHistoryMetric beforeMetrics = CurrentMetrics;
if (!this.ShouldCompact(beforeMetrics))
{
// %%% VERBOSE - Debug.WriteLine($"COMPACTION: {this.Name} - Skipped");
return CompactionResult.Skipped(this.Name, beforeMetrics);
}
Debug.WriteLine($"COMPACTION: {this.Name} - Reducing");
IEnumerable<ChatMessage> reducerResult = await this.Reducer.ReduceAsync(history, cancellationToken).ConfigureAwait(false);
// Ensure we have a concrete collection to avoid multiple enumerations of the reducer result, which could be costly if it's an iterator.
ChatMessage[] reducedCopy = [.. reducerResult];
bool modified = reducedCopy.Length != history.Count;
if (modified)
{
history.Clear();
history.AddRange(reducedCopy);
}
ChatHistoryMetric afterMetrics = modified
? metricsCalculator.Calculate(reducedCopy)
: beforeMetrics;
Debug.WriteLine($"COMPACTION: {this.Name} - Tokens {beforeMetrics.TokenCount} => {afterMetrics.TokenCount}");
return new(this.Name, applied: modified, beforeMetrics, afterMetrics);
}
}
@@ -1,45 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Immutable snapshot of conversation metrics used for compaction trigger evaluation and reporting.
/// </summary>
public sealed class ChatHistoryMetric
{
/// <summary>
/// Gets the estimated token count across all messages.
/// </summary>
public int TokenCount { get; init; }
/// <summary>
/// Gets the total serialized byte count of all messages.
/// </summary>
public long ByteCount { get; init; }
#pragma warning disable IDE0001 // Simplify Names
/// <summary>
/// Gets the total number of <see cref="Microsoft.Extensions.AI.ChatMessage"/> objects.
/// </summary>
#pragma warning restore IDE0001 // Simplify Names
public int MessageCount { get; init; }
/// <summary>
/// Gets the number of tool/function call content items across all messages.
/// </summary>
public int ToolCallCount { get; init; }
/// <summary>
/// Gets the number of user turns. A user turn is a user message together with the full
/// set of agent responses (including tool calls and results) before the next user input.
/// </summary>
public int UserTurnCount { get; init; }
/// <summary>
/// Gets the atomic message group index for the analyzed messages.
/// Each group represents a contiguous range of messages that must be kept or removed together.
/// </summary>
public IReadOnlyList<ChatMessageGroup> Groups { get; init; } = [];
}
@@ -1,64 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Represents a contiguous range of messages in a conversation that form an atomic group.
/// Atomic groups must be kept or removed together to maintain API correctness.
/// </summary>
/// <remarks>
/// For example, an assistant message containing tool calls and the subsequent tool result messages
/// form an atomic group — removing one without the other causes API errors.
/// </remarks>
public readonly struct ChatMessageGroup : IEquatable<ChatMessageGroup>
{
/// <summary>
/// Initializes a new instance of the <see cref="ChatMessageGroup"/> struct.
/// </summary>
/// <param name="startIndex">The zero-based index of the first message in this group.</param>
/// <param name="count">The number of messages in this group.</param>
/// <param name="kind">The kind of this message group.</param>
public ChatMessageGroup(int startIndex, int count, ChatMessageGroupKind kind)
{
this.StartIndex = startIndex;
this.Count = count;
this.Kind = kind;
}
/// <summary>
/// Gets the zero-based index of the first message in this group within the original message list.
/// </summary>
public int StartIndex { get; }
/// <summary>
/// Gets the number of messages in this group.
/// </summary>
public int Count { get; }
/// <summary>
/// Gets the kind of this message group.
/// </summary>
public ChatMessageGroupKind Kind { get; }
/// <inheritdoc/>
public bool Equals(ChatMessageGroup other) =>
this.StartIndex == other.StartIndex &&
this.Count == other.Count &&
this.Kind == other.Kind;
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is ChatMessageGroup other &&
this.Equals(other);
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(this.StartIndex, this.Count, (int)this.Kind);
/// <summary>Determines whether two <see cref="ChatMessageGroup"/> instances are equal.</summary>
public static bool operator ==(ChatMessageGroup left, ChatMessageGroup right) => left.Equals(right);
/// <summary>Determines whether two <see cref="ChatMessageGroup"/> instances are not equal.</summary>
public static bool operator !=(ChatMessageGroup left, ChatMessageGroup right) => !left.Equals(right);
}
@@ -1,27 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Identifies the kind of an atomic message group in a conversation.
/// </summary>
public enum ChatMessageGroupKind
{
/// <summary>A system message.</summary>
System,
/// <summary>A user message (start of a user turn).</summary>
UserTurn,
/// <summary>An assistant message with tool calls and their corresponding tool result messages.</summary>
AssistantToolGroup,
/// <summary>An assistant message without tool calls.</summary>
AssistantPlain,
/// <summary>A tool result message that is not part of a recognized group.</summary>
ToolResult,
/// <summary>A message with an unrecognized role.</summary>
Other
}
@@ -1,35 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Represents a chat history compaction strategy that uses a condition function to determine when compaction should
/// occur.
/// </summary>
/// <remarks>
/// This strategy evaluates a user-provided condition against compaction metrics to decide whether to
/// compact the chat history. It is useful for scenarios where compaction should be triggered based on custom thresholds
/// or criteria. Inherits from ChatHistoryCompactionStrategy.
/// </remarks>
public class ChatReducerCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly Func<ChatHistoryMetric, bool> _condition;
/// <summary>
/// Initializes a new instance of the <see cref="ChatReducerCompactionStrategy"/> class.
/// </summary>
public ChatReducerCompactionStrategy(
IChatReducer reducer,
Func<ChatHistoryMetric, bool> condition)
: base(reducer)
{
this._condition = Throw.IfNull(condition);
}
/// <inheritdoc/>
protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._condition(metrics);
}
@@ -1,49 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Reports the aggregate outcome of a <see cref="ChatHistoryCompactionPipeline"/> execution.
/// </summary>
public sealed class CompactionPipelineResult
{
/// <summary>
/// Initializes a new instance of the <see cref="CompactionPipelineResult"/> class.
/// </summary>
/// <param name="before">Metrics of the conversation before any strategy ran.</param>
/// <param name="after">Metrics of the conversation after all strategies ran.</param>
/// <param name="strategyResults">Per-strategy results in execution order.</param>
internal CompactionPipelineResult(
ChatHistoryMetric before,
ChatHistoryMetric after,
IReadOnlyList<CompactionResult> strategyResults)
{
this.Before = Throw.IfNull(before);
this.After = Throw.IfNull(after);
this.StrategyResults = Throw.IfNull(strategyResults);
}
/// <summary>
/// Gets the conversation metrics before any compaction strategy ran.
/// </summary>
public ChatHistoryMetric Before { get; }
/// <summary>
/// Gets the conversation metrics after all compaction strategies ran.
/// </summary>
public ChatHistoryMetric After { get; }
/// <summary>
/// Gets the per-strategy results in execution order.
/// </summary>
public IReadOnlyList<CompactionResult> StrategyResults { get; }
/// <summary>
/// Gets a value indicating whether any strategy modified the message list.
/// </summary>
public bool AnyApplied => this.StrategyResults.Any(r => r.Applied);
}
@@ -1,55 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Reports the outcome of a single <see cref="ChatHistoryCompactionStrategy"/> execution.
/// </summary>
public sealed class CompactionResult
{
/// <summary>
/// Initializes a new instance of the <see cref="CompactionResult"/> class.
/// </summary>
/// <param name="strategyName">The name of the strategy that produced this result.</param>
/// <param name="applied">Whether the strategy modified the message list.</param>
/// <param name="before">Metrics before the strategy ran.</param>
/// <param name="after">Metrics after the strategy ran.</param>
public CompactionResult(string strategyName, bool applied, ChatHistoryMetric before, ChatHistoryMetric after)
{
this.StrategyName = Throw.IfNullOrWhitespace(strategyName);
this.Applied = applied;
this.Before = Throw.IfNull(before);
this.After = Throw.IfNull(after);
}
/// <summary>
/// Gets the name of the strategy that produced this result.
/// </summary>
public string StrategyName { get; }
/// <summary>
/// Gets a value indicating whether the strategy modified the message list.
/// </summary>
public bool Applied { get; }
/// <summary>
/// Gets the conversation metrics before the strategy executed.
/// </summary>
public ChatHistoryMetric Before { get; }
/// <summary>
/// Gets the conversation metrics after the strategy executed.
/// </summary>
public ChatHistoryMetric After { get; }
/// <summary>
/// Creates a <see cref="CompactionResult"/> representing a skipped strategy.
/// </summary>
/// <param name="strategyName">The name of the skipped strategy.</param>
/// <param name="metrics">The current conversation metrics.</param>
/// <returns>A result indicating no compaction was applied.</returns>
internal static CompactionResult Skipped(string strategyName, ChatHistoryMetric metrics)
=> new(strategyName, applied: false, metrics, metrics);
}
@@ -1,161 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Default implementation of <see cref="IChatHistoryMetricsCalculator"/> that uses
/// JSON serialization length heuristics for token and byte estimation.
/// </summary>
/// <remarks>
/// <para>
/// Token estimation uses a configurable characters-per-token ratio (default ~4) since
/// precise tokenization requires a model-specific tokenizer. For production workloads
/// requiring accurate token counts, implement <see cref="IChatHistoryMetricsCalculator"/>
/// with a model-appropriate tokenizer.
/// </para>
/// </remarks>
public sealed class DefaultChatHistoryMetricsCalculator : IChatHistoryMetricsCalculator
{
/// <summary>
/// Gets the singleton instance of the chat history metrics calculator.
/// </summary>
/// <remarks>
/// <see cref="DefaultChatHistoryMetricsCalculator"/> can be safety accessed by
/// concurrent threads.
/// </remarks>
public static readonly DefaultChatHistoryMetricsCalculator Instance = new();
private const int DefaultCharsPerToken = 4;
private const int PerMessageOverheadTokens = 4;
private readonly int _charsPerToken;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultChatHistoryMetricsCalculator"/> class.
/// </summary>
/// <param name="charsPerToken">
/// The approximate number of characters per token used for estimation. Default is 4.
/// </param>
public DefaultChatHistoryMetricsCalculator(int charsPerToken = DefaultCharsPerToken)
{
this._charsPerToken = charsPerToken > 0 ? charsPerToken : DefaultCharsPerToken;
}
/// <inheritdoc/>
public ChatHistoryMetric Calculate(IReadOnlyList<ChatMessage> messages)
{
if (messages is null || messages.Count == 0)
{
return new();
}
int totalTokens = 0;
long totalBytes = 0;
int toolCallCount = 0;
int userTurnCount = 0;
bool inUserTurn = false;
List<ChatMessageGroup> groups = [];
int index = 0;
while (index < messages.Count)
{
ChatMessage message = messages[index];
// Accumulate per-message metrics
this.AccumulateMessageMetrics(message, ref totalTokens, ref totalBytes, ref toolCallCount);
if (message.Role == ChatRole.User)
{
if (!inUserTurn)
{
userTurnCount++;
inUserTurn = true;
}
}
else
{
inUserTurn = false;
}
// Identify the group starting at this message
if (message.Role == ChatRole.System)
{
groups.Add(new(index, 1, ChatMessageGroupKind.System));
index++;
}
else if (message.Role == ChatRole.User)
{
groups.Add(new(index, 1, ChatMessageGroupKind.UserTurn));
index++;
}
else if (message.Role == ChatRole.Assistant)
{
bool hasToolCalls = message.Contents!.Any(c => c is FunctionCallContent);
if (hasToolCalls)
{
int groupStart = index;
index++;
while (index < messages.Count && messages[index].Role == ChatRole.Tool)
{
this.AccumulateMessageMetrics(messages[index], ref totalTokens, ref totalBytes, ref toolCallCount);
inUserTurn = false;
index++;
}
groups.Add(new(groupStart, index - groupStart, ChatMessageGroupKind.AssistantToolGroup));
}
else
{
groups.Add(new(index, 1, ChatMessageGroupKind.AssistantPlain));
index++;
}
}
else if (message.Role == ChatRole.Tool)
{
groups.Add(new(index, 1, ChatMessageGroupKind.ToolResult));
index++;
}
else
{
groups.Add(new(index, 1, ChatMessageGroupKind.Other));
index++;
}
}
return new()
{
TokenCount = totalTokens,
ByteCount = totalBytes,
MessageCount = messages.Count,
ToolCallCount = toolCallCount,
UserTurnCount = userTurnCount,
Groups = groups
};
}
private void AccumulateMessageMetrics(ChatMessage message, ref int totalTokens, ref long totalBytes, ref int toolCallCount)
{
string serialized = message.Text;
int charCount = serialized.Length;
totalBytes += System.Text.Encoding.UTF8.GetByteCount(serialized);
totalTokens += (charCount / this._charsPerToken) + PerMessageOverheadTokens;
if (message.Contents is not null)
{
foreach (AIContent content in message.Contents)
{
if (content is FunctionCallContent)
{
toolCallCount++;
}
}
}
}
}
@@ -1,26 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
// %%% TODO: Is this interface needed? Consider whether the default implementation is sufficient
// and whether custom metrics calculators are a realistic extension point.
/// <summary>
/// Computes <see cref="ChatHistoryMetric"/> for a list of messages.
/// </summary>
/// <remarks>
/// Token counting is model-specific. Implementations can provide precise tokenization
/// (e.g., using tiktoken or a model-specific tokenizer) or use estimation heuristics.
/// </remarks>
public interface IChatHistoryMetricsCalculator
{
/// <summary>
/// Compute metrics for the given messages.
/// </summary>
/// <param name="messages">The messages to analyze.</param>
/// <returns>A <see cref="ChatHistoryMetric"/> snapshot.</returns>
ChatHistoryMetric Calculate(IReadOnlyList<ChatMessage> messages);
}
@@ -1,91 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that keeps only the most recent user turns and their
/// associated response groups, removing older turns to bound conversation length.
/// </summary>
/// <remarks>
/// <para>
/// This strategy always preserves system messages. It identifies user turns in the
/// conversation and keeps the last <c>maxTurns</c> turns along with all response groups
/// (assistant replies, tool call groups) that follow each kept turn.
/// </para>
/// <para>
/// The trigger condition fires only when the number of user turns exceeds <c>maxTurns</c>.
/// </para>
/// <para>
/// This strategy is more predictable than token-based truncation for bounding conversation
/// length, since it operates on logical turn boundaries rather than estimated token counts.
/// </para>
/// </remarks>
public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly int _maxTurns;
/// <summary>
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
/// </summary>
/// <param name="maxTurns">
/// The maximum number of user turns to keep. Older turns and their associated responses are removed.
/// </param>
public SlidingWindowCompactionStrategy(int maxTurns)
: base(new SlidingWindowReducer(maxTurns))
{
this._maxTurns = maxTurns;
}
/// <inheritdoc/>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.UserTurnCount > this._maxTurns;
/// <summary>
/// An <see cref="IChatReducer"/> that keeps system messages and the last N user turns
/// with all their associated response groups.
/// </summary>
private sealed class SlidingWindowReducer(int maxTurns) : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages]; // %%% PERFORMANCE
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
// Find the group-list indices where each user turn starts
int[] turnGroupIndices =
[.. CurrentMetrics.Groups
.Select((group, index) => (group, index))
.Where(t => t.group.Kind == ChatMessageGroupKind.UserTurn)
.Select(t => t.index)];
// Keep the last maxTurns user turns and everything after the first kept turn
int firstKeptTurnIndex = turnGroupIndices.Length - maxTurns;
int firstKeptGroupIndex = turnGroupIndices[firstKeptTurnIndex];
List<ChatMessage> result = new(messageList.Count); // %%% PERFORMANCE
for (int gi = 0; gi < groups.Count; gi++)
{
ChatMessageGroup group = groups[gi];
// Always keep system messages; keep groups at or after the window start
if (group.Kind == ChatMessageGroupKind.System || gi >= firstKeptGroupIndex)
{
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
{
result.Add(messageList[j]);
}
}
}
return Task.FromResult<IEnumerable<ChatMessage>>(result);
}
}
}
@@ -1,166 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that uses an LLM to summarize older portions of the conversation,
/// replacing them with a concise summary message that preserves key facts and context.
/// </summary>
/// <remarks>
/// <para>
/// This strategy sits between tool-result clearing (gentle) and truncation (aggressive) in the
/// compaction ladder. Unlike truncation which discards messages entirely, summarization preserves
/// the essential information in compressed form, allowing the agent to maintain awareness of
/// earlier context.
/// </para>
/// <para>
/// The strategy protects system messages and the most recent <c>preserveRecentGroups</c>
/// non-system groups. All older groups are collected and sent to the <see cref="IChatClient"/>
/// for summarization. The resulting summary replaces those messages as a single assistant message.
/// </para>
/// </remarks>
public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly int _maxTokens;
/// <summary>
/// The default summarization prompt used when none is provided.
/// </summary>
public const string DefaultSummarizationPrompt =
"""
You are a conversation summarizer. Produce a concise summary of the conversation that preserves:
- Key facts, decisions, and user preferences
- Important context needed for future turns
- Tool call outcomes and their significance
Omit pleasantries and redundant exchanges. Be factual and brief.
""";
/// <summary>
/// Initializes a new instance of the <see cref="SummarizationCompactionStrategy"/> class.
/// </summary>
/// <param name="chatClient">The <see cref="IChatClient"/> to use for generating summaries. A smaller, faster model is recommended.</param>
/// <param name="maxTokens">The maximum token budget. Summarization is triggered when the token count exceeds this value.</param>
/// <param name="preserveRecentGroups">
/// The number of most-recent non-system message groups to protect from summarization.
/// Defaults to 4, preserving the current and recent exchanges.
/// </param>
/// <param name="summarizationPrompt">
/// An optional custom system prompt for the summarization LLM call. When <see langword="null"/>,
/// a default prompt that emphasizes fact-preservation is used.
/// </param>
public SummarizationCompactionStrategy(
IChatClient chatClient,
int maxTokens,
int preserveRecentGroups = 4,
string? summarizationPrompt = null)
: base(new SummarizationReducer(chatClient, preserveRecentGroups, summarizationPrompt ?? DefaultSummarizationPrompt))
{
this._maxTokens = maxTokens;
}
/// <inheritdoc/>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.TokenCount > this._maxTokens;
/// <summary>
/// An <see cref="IChatReducer"/> that sends older message groups to an LLM for summarization,
/// then replaces them with a single summary message.
/// </summary>
private sealed class SummarizationReducer : IChatReducer
{
private readonly IChatClient _chatClient;
private readonly int _preserveRecentGroups;
private readonly string _summarizationPrompt;
public SummarizationReducer(IChatClient chatClient, int preserveRecentGroups, string summarizationPrompt)
{
this._chatClient = Throw.IfNull(chatClient);
this._preserveRecentGroups = preserveRecentGroups;
this._summarizationPrompt = Throw.IfNullOrEmpty(summarizationPrompt);
}
public async Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
List<ChatMessageGroup> nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - this._preserveRecentGroups);
if (protectedFromIndex == 0)
{
// Nothing to summarize — all groups are protected
return messageList;
}
// Collect messages from groups that will be summarized
List<ChatMessage> toSummarize = [];
for (int i = 0; i < protectedFromIndex; i++)
{
ChatMessageGroup group = nonSystemGroups[i];
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
{
toSummarize.Add(messageList[j]);
}
}
if (toSummarize.Count == 0)
{
return messageList;
}
// Build the summarization request
List<ChatMessage> summarizationRequest =
[
new(ChatRole.System, this._summarizationPrompt),
.. toSummarize,
new(ChatRole.User, "Summarize the conversation above concisely."),
];
ChatResponse response = await this._chatClient.GetResponseAsync(summarizationRequest, cancellationToken: cancellationToken).ConfigureAwait(false);
string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
// Build result: system groups + summary + protected groups
List<ChatMessage> result = [];
// Keep system messages
foreach (ChatMessageGroup group in groups)
{
if (group.Kind == ChatMessageGroupKind.System)
{
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
{
result.Add(messageList[j]);
}
}
}
// Insert summary
result.Add(new ChatMessage(ChatRole.Assistant, $"[Summary]\n{summaryText}"));
// Keep protected groups
for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++)
{
ChatMessageGroup group = nonSystemGroups[i];
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
{
result.Add(messageList[j]);
}
}
return result;
}
}
}
@@ -1,118 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that collapses old assistant-tool-call groups into single
/// concise assistant messages, removing the detailed tool results while preserving
/// a record of which tools were called.
/// </summary>
/// <remarks>
/// <para>
/// This is the gentlest compaction strategy — it does not remove any user messages or
/// plain assistant responses. It only targets <see cref="ChatMessageGroupKind.AssistantToolGroup"/>
/// entries outside the protected recent window, replacing each multi-message group
/// (assistant call + tool results) with a single assistant message like
/// <c>[Tool calls: get_weather, search_docs]</c>.
/// </para>
/// <para>
/// The trigger condition fires only when token count exceeds <c>maxTokens</c> and
/// there is at least one tool call in the conversation.
/// </para>
/// </remarks>
public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy
{
/// <summary>
/// The default value for `preserveRecentGroups` used when constructing <see cref="ToolResultCompactionStrategy"/>.
/// </summary>
public const int DefaultPreserveRecentGroups = 2;
private readonly int _maxTokens;
/// <summary>
/// Initializes a new instance of the <see cref="ToolResultCompactionStrategy"/> class.
/// </summary>
/// <param name="maxTokens">The maximum token budget. Tool groups are collapsed when the token count exceeds this value.</param>
/// <param name="preserveRecentGroups">
/// The number of most-recent non-system message groups to protect from collapsing.
/// Defaults to 2, ensuring the current turn's tool interactions remain visible.
/// </param>
public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = DefaultPreserveRecentGroups)
: base(new ToolResultClearingReducer(preserveRecentGroups))
{
this._maxTokens = maxTokens;
}
/// <inheritdoc/>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.TokenCount > this._maxTokens && metrics.ToolCallCount > 0;
/// <summary>
/// An <see cref="IChatReducer"/> that collapses <see cref="ChatMessageGroupKind.AssistantToolGroup"/>
/// entries into single summary messages, preserving the most recent groups.
/// </summary>
private sealed class ToolResultClearingReducer(int preserveRecentGroups) : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
List<ChatMessageGroup> nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - preserveRecentGroups);
HashSet<int> protectedGroupStarts = [];
for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++)
{
protectedGroupStarts.Add(nonSystemGroups[i].StartIndex);
}
List<ChatMessage> result = new(messageList.Count);
bool anyCollapsed = false;
foreach (ChatMessageGroup group in groups)
{
if (group.Kind == ChatMessageGroupKind.AssistantToolGroup && !protectedGroupStarts.Contains(group.StartIndex))
{
// Collapse this tool group into a single summary message
List<string> toolNames = [];
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
{
if (messageList[j].Contents is not null)
{
foreach (AIContent content in messageList[j].Contents)
{
if (content is FunctionCallContent fcc)
{
toolNames.Add(fcc.Name);
}
}
}
}
string summary = $"[Tool calls: {string.Join(", ", toolNames)}]";
result.Add(new ChatMessage(ChatRole.Assistant, summary));
anyCollapsed = true;
}
else
{
// Keep this group as-is
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
{
result.Add(messageList[j]);
}
}
}
return Task.FromResult<IEnumerable<ChatMessage>>(anyCollapsed ? result : messageList);
}
}
}
@@ -1,97 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that removes the oldest message groups until the estimated
/// token count is within a specified budget.
/// </summary>
/// <remarks>
/// <para>
/// This strategy preserves system messages and removes the oldest non-system message groups first.
/// It respects atomic group boundaries — an assistant message with tool calls and its
/// corresponding tool result messages are always removed together.
/// </para>
/// <para>
/// The trigger condition fires only when the current token count exceeds <c>maxTokens</c>.
/// </para>
/// </remarks>
public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly int _maxTokens;
/// <summary>
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
/// </summary>
/// <param name="maxTokens">The maximum token budget. Groups are removed until the token count is at or below this value.</param>
/// <param name="preserveRecentGroups">
/// The minimum number of most-recent non-system message groups to keep.
/// Defaults to 1 so that at least the latest exchange is always preserved.
/// </param>
public TruncationCompactionStrategy(int maxTokens, int preserveRecentGroups = 1)
: base(new TruncationReducer(preserveRecentGroups))
{
this._maxTokens = maxTokens;
}
/// <inheritdoc/>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.TokenCount > this._maxTokens;
/// <summary>
/// An <see cref="IChatReducer"/> that removes the oldest non-system message groups,
/// keeping at least the most recent group.
/// </summary>
private sealed class TruncationReducer(int preserveRecentGroups) : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
ChatMessageGroup[] removableGroups = [.. CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
if (removableGroups.Length == 0)
{
return Task.FromResult<IEnumerable<ChatMessage>>(messageList);
}
// Remove oldest non-system groups, keeping at least preserveRecentGroups.
int maxRemovable = removableGroups.Length - preserveRecentGroups;
if (maxRemovable <= 0)
{
return Task.FromResult<IEnumerable<ChatMessage>>(messageList);
}
HashSet<int> removedGroupStarts = [];
for (int ri = 0; ri < maxRemovable; ri++)
{
removedGroupStarts.Add(removableGroups[ri].StartIndex);
}
List<ChatMessage> messagesToKeep = new(messageList.Count);
foreach (ChatMessageGroup group in CurrentMetrics.Groups)
{
if (removedGroupStarts.Contains(group.StartIndex))
{
continue;
}
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
{
messagesToKeep.Add(messageList[j]);
}
}
return Task.FromResult<IEnumerable<ChatMessage>>(messagesToKeep);
}
}
}
@@ -31,10 +31,6 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<PackageReference Include="Microsoft.Bcl.HashCode" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Abstractions.UnitTests" />
</ItemGroup>
@@ -19,9 +19,10 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="services">The service collection to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(services);
Throw.IfNullOrEmpty(name);
@@ -30,7 +31,7 @@ public static class AgentHostingServiceCollectionExtensions
var chatClient = sp.GetRequiredService<IChatClient>();
var tools = sp.GetKeyedServices<AITool>(name).ToList();
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
});
}, lifetime);
}
/// <summary>
@@ -40,9 +41,10 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, IChatClient chatClient)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, IChatClient chatClient, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(services);
Throw.IfNullOrEmpty(name);
@@ -50,7 +52,7 @@ public static class AgentHostingServiceCollectionExtensions
{
var tools = sp.GetKeyedServices<AITool>(name).ToList();
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
});
}, lifetime);
}
/// <summary>
@@ -60,9 +62,10 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If <see langword="null"/>, a non-keyed service will be resolved.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, object? chatClientServiceKey)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(services);
Throw.IfNullOrEmpty(name);
@@ -71,7 +74,7 @@ public static class AgentHostingServiceCollectionExtensions
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
var tools = sp.GetKeyedServices<AITool>(name).ToList();
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
});
}, lifetime);
}
/// <summary>
@@ -82,9 +85,10 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="description">A description of the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If <see langword="null"/>, a non-keyed service will be resolved.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, string? description, object? chatClientServiceKey)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, string? description, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(services);
Throw.IfNullOrEmpty(name);
@@ -93,7 +97,7 @@ public static class AgentHostingServiceCollectionExtensions
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
var tools = sp.GetKeyedServices<AITool>(name).ToList();
return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description, tools: tools);
});
}, lifetime);
}
/// <summary>
@@ -102,15 +106,16 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="services">The service collection to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns <see langword="null"/> or an agent whose <see cref="AIAgent.Name"/> does not match <paramref name="name"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(services);
Throw.IfNull(name);
Throw.IfNull(createAgentDelegate);
services.AddKeyedSingleton(name, (sp, key) =>
services.AddKeyedService(name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
@@ -122,8 +127,18 @@ public static class AgentHostingServiceCollectionExtensions
}
return agent;
});
}, lifetime);
return new HostedAgentBuilder(name, services);
return new HostedAgentBuilder(name, services, lifetime);
}
/// <summary>
/// Registers a keyed service with the specified lifetime.
/// </summary>
internal static void AddKeyedService<T>(this IServiceCollection services, object? serviceKey, Func<IServiceProvider, object?, T> factory, ServiceLifetime lifetime)
where T : class
{
var descriptor = new ServiceDescriptor(typeof(T), serviceKey, (sp, key) => factory(sp, key), lifetime);
services.Add(descriptor);
}
}
@@ -2,6 +2,7 @@
using System;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Shared.Diagnostics;
@@ -18,12 +19,13 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="builder">The host application builder to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(builder);
return builder.Services.AddAIAgent(name, instructions);
return builder.Services.AddAIAgent(name, instructions, lifetime);
}
/// <summary>
@@ -33,13 +35,14 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(builder);
Throw.IfNullOrEmpty(name);
return builder.Services.AddAIAgent(name, instructions, chatClient);
return builder.Services.AddAIAgent(name, instructions, chatClient, lifetime);
}
/// <summary>
@@ -50,13 +53,14 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="description">A description of the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(builder);
Throw.IfNullOrEmpty(name);
return builder.Services.AddAIAgent(name, instructions, description, chatClientServiceKey);
return builder.Services.AddAIAgent(name, instructions, description, chatClientServiceKey, lifetime);
}
/// <summary>
@@ -66,12 +70,13 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(builder);
return builder.Services.AddAIAgent(name, instructions, chatClientServiceKey);
return builder.Services.AddAIAgent(name, instructions, chatClientServiceKey, lifetime);
}
/// <summary>
@@ -80,12 +85,13 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="builder">The host application builder to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns null or an invalid AI agent instance.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(builder);
return builder.Services.AddAIAgent(name, createAgentDelegate);
return builder.Services.AddAIAgent(name, createAgentDelegate, lifetime);
}
}
@@ -19,19 +19,20 @@ public static class HostApplicationBuilderWorkflowExtensions
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <param name="name">The unique name for the workflow.</param>
/// <param name="createWorkflowDelegate">A factory function that creates the <see cref="Workflow"/> instance. The function receives the service provider and workflow name as parameters.</param>
/// <param name="lifetime">The DI service lifetime for the workflow registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>An <see cref="IHostedWorkflowBuilder"/> that can be used to further configure the workflow.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createWorkflowDelegate"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is empty.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the factory delegate returns null or a workflow with a name that doesn't match the expected name.
/// </exception>
public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, Workflow> createWorkflowDelegate)
public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, Workflow> createWorkflowDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Throw.IfNull(builder);
Throw.IfNull(name);
Throw.IfNull(createWorkflowDelegate);
builder.Services.AddKeyedSingleton(name, (sp, key) =>
builder.Services.AddKeyedService(name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
@@ -43,7 +44,7 @@ public static class HostApplicationBuilderWorkflowExtensions
}
return workflow;
});
}, lifetime);
return new HostedWorkflowBuilder(name, builder);
}
@@ -9,15 +9,17 @@ internal sealed class HostedAgentBuilder : IHostedAgentBuilder
{
public string Name { get; }
public IServiceCollection ServiceCollection { get; }
public ServiceLifetime Lifetime { get; }
public HostedAgentBuilder(string name, IHostApplicationBuilder builder)
: this(name, builder.Services)
public HostedAgentBuilder(string name, IHostApplicationBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton)
: this(name, builder.Services, lifetime)
{
}
public HostedAgentBuilder(string name, IServiceCollection serviceCollection)
public HostedAgentBuilder(string name, IServiceCollection serviceCollection, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
this.Name = name;
this.ServiceCollection = serviceCollection;
this.Lifetime = lifetime;
}
}
@@ -42,17 +42,19 @@ public static class HostedAgentBuilderExtensions
/// <param name="builder">The host agent builder to configure.</param>
/// <param name="createAgentSessionStore">A factory function that creates an agent session store instance using the provided service provider and agent
/// name.</param>
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.</param>
/// <returns>The same host agent builder instance, enabling further configuration.</returns>
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore)
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
builder.ServiceCollection.AddKeyedSingleton(builder.Name, (sp, key) =>
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
Throw.IfNullOrEmpty(keyString);
return createAgentSessionStore(sp, keyString) ??
throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'.");
});
}, lifetime);
return builder;
}
@@ -98,13 +100,39 @@ public static class HostedAgentBuilderExtensions
/// </summary>
/// <param name="builder">The hosted agent builder.</param>
/// <param name="factory">A factory function that creates a AI tool using the provided service provider.</param>
public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, Func<IServiceProvider, AITool> factory)
/// <param name="lifetime">The DI service lifetime for the tool registration. If <see langword="null"/>, the agent's lifetime is used.</param>
/// <returns>The same <see cref="IHostedAgentBuilder"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="factory"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the effective tool lifetime is shorter than the agent's lifetime, which would cause a captive dependency.
/// For example, a singleton agent cannot use scoped or transient tools.
/// </exception>
public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, Func<IServiceProvider, AITool> factory, ServiceLifetime? lifetime = null)
{
Throw.IfNull(builder);
Throw.IfNull(factory);
builder.ServiceCollection.AddKeyedSingleton(builder.Name, (sp, name) => factory(sp));
var effectiveLifetime = lifetime ?? builder.Lifetime;
ValidateToolLifetime(builder.Lifetime, effectiveLifetime);
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, name) => factory(sp), effectiveLifetime);
return builder;
}
/// <summary>
/// Validates that the tool lifetime is compatible with the agent lifetime.
/// A tool's lifetime must be at least as long as the agent's lifetime to prevent captive dependency issues.
/// </summary>
internal static void ValidateToolLifetime(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
{
// ServiceLifetime enum: Singleton=0, Scoped=1, Transient=2
// A higher value means a shorter lifetime.
if (toolLifetime > agentLifetime)
{
throw new InvalidOperationException(
$"A tool with lifetime '{toolLifetime}' cannot be registered for an agent with lifetime '{agentLifetime}'. " +
"The tool's lifetime must be at least as long as the agent's lifetime to avoid captive dependency issues.");
}
}
}
@@ -14,22 +14,24 @@ public static class HostedWorkflowBuilderExtensions
/// Registers the workflow as an AI agent in the dependency injection container.
/// </summary>
/// <param name="builder">The <see cref="IHostedWorkflowBuilder"/> instance to extend.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder)
=> builder.AddAsAIAgent(name: null);
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton)
=> builder.AddAsAIAgent(name: null, lifetime: lifetime);
/// <summary>
/// Registers the workflow as an AI agent in the dependency injection container.
/// </summary>
/// <param name="builder">The <see cref="IHostedWorkflowBuilder"/> instance to extend.</param>
/// <param name="name">The optional name for the AI agent. If not specified, the workflow name is used.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name)
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
var workflowName = builder.Name;
var agentName = name ?? workflowName;
return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) =>
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAIAgent(name: key));
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAIAgent(name: key), lifetime);
}
}
@@ -18,4 +18,9 @@ public interface IHostedAgentBuilder
/// Gets the service collection for configuration.
/// </summary>
IServiceCollection ServiceCollection { get; }
/// <summary>
/// Gets the DI service lifetime used for the agent registration.
/// </summary>
ServiceLifetime Lifetime { get; }
}
@@ -350,36 +350,38 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
string? userId = searchScope.UserId;
string? sessionId = searchScope.SessionId;
Expression<Func<Dictionary<string, object?>, bool>>? filter = null;
// Build a combined filter using a single shared parameter to avoid expression tree
// scoping issues when multiple filters are combined with AndAlso.
ParameterExpression parameter = Expression.Parameter(typeof(Dictionary<string, object?>), "x");
Expression? filterBody = null;
if (applicationId != null)
{
filter = x => (string?)x[ApplicationIdField] == applicationId;
filterBody = RebindFilterBody(x => (string?)x[ApplicationIdField] == applicationId, parameter);
}
if (agentId != null)
{
Expression<Func<Dictionary<string, object?>, bool>> agentIdFilter = x => (string?)x[AgentIdField] == agentId;
filter = filter == null ? agentIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
Expression.AndAlso(filter.Body, agentIdFilter.Body),
filter.Parameters);
Expression body = RebindFilterBody(x => (string?)x[AgentIdField] == agentId, parameter);
filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body);
}
if (userId != null)
{
Expression<Func<Dictionary<string, object?>, bool>> userIdFilter = x => (string?)x[UserIdField] == userId;
filter = filter == null ? userIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
Expression.AndAlso(filter.Body, userIdFilter.Body),
filter.Parameters);
Expression body = RebindFilterBody(x => (string?)x[UserIdField] == userId, parameter);
filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body);
}
if (sessionId != null)
{
Expression<Func<Dictionary<string, object?>, bool>> sessionIdFilter = x => (string?)x[SessionIdField] == sessionId;
filter = filter == null ? sessionIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
Expression.AndAlso(filter.Body, sessionIdFilter.Body),
filter.Parameters);
Expression body = RebindFilterBody(x => (string?)x[SessionIdField] == sessionId, parameter);
filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body);
}
Expression<Func<Dictionary<string, object?>, bool>>? filter = filterBody != null
? Expression.Lambda<Func<Dictionary<string, object?>, bool>>(filterBody, parameter)
: null;
// Use search to find relevant messages
var searchResults = collection.SearchAsync(
queryText,
@@ -467,6 +469,27 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : "<redacted>";
/// <summary>
/// Rebinds a filter expression's body to use the specified shared parameter,
/// replacing the original lambda parameter so that multiple filters can be safely
/// combined with <see cref="Expression.AndAlso(Expression, Expression)"/>.
/// </summary>
private static Expression RebindFilterBody(
Expression<Func<Dictionary<string, object?>, bool>> filter,
ParameterExpression sharedParameter)
{
return new ParameterReplacer(filter.Parameters[0], sharedParameter).Visit(filter.Body);
}
/// <summary>
/// An <see cref="ExpressionVisitor"/> that replaces one <see cref="ParameterExpression"/> with another.
/// </summary>
private sealed class ParameterReplacer(ParameterExpression original, ParameterExpression replacement) : ExpressionVisitor
{
protected override Expression VisitParameter(ParameterExpression node)
=> node == original ? replacement : base.VisitParameter(node);
}
/// <summary>
/// Represents the state of a <see cref="ChatHistoryMemoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
@@ -40,9 +40,10 @@ internal sealed partial class FileAgentSkillLoader
// "description: \"A skill\"" → (description, A skill, _)
private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Validates skill names: lowercase letters, numbers, and hyphens only; must not start or end with a hyphen.
// Examples: "my-skill" âś“, "skill123" âś“, "-bad" âś—, "bad-" âś—, "Bad" âś—
private static readonly Regex s_validNameRegex = new(@"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$", RegexOptions.Compiled);
// Validates skill names: lowercase letters, numbers, and hyphens only;
// must not start or end with a hyphen; must not contain consecutive hyphens.
// Examples: "my-skill" âś“, "skill123" âś“, "-bad" âś—, "bad-" âś—, "Bad" âś—, "my--skill" âś—
private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled);
private readonly ILogger _logger;
private readonly HashSet<string> _allowedResourceExtensions;
@@ -244,7 +245,22 @@ internal sealed partial class FileAgentSkillLoader
if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name))
{
LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen.");
LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens.");
return false;
}
// skillFilePath is e.g. "/skills/my-skill/SKILL.md".
// GetDirectoryName strips the filename → "/skills/my-skill".
// GetFileName then extracts the last segment → "my-skill".
// This gives us the skill's parent directory name to validate against the frontmatter name.
string directoryName = Path.GetFileName(Path.GetDirectoryName(skillFilePath)) ?? string.Empty;
if (!string.Equals(name, directoryName, StringComparison.Ordinal))
{
if (this._logger.IsEnabled(LogLevel.Error))
{
LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), name, SanitizePathForLog(directoryName));
}
return false;
}
@@ -457,6 +473,9 @@ internal sealed partial class FileAgentSkillLoader
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")]
private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason);
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}': skill name '{SkillName}' does not match parent directory name '{DirectoryName}'")]
private static partial void LogNameDirectoryMismatch(ILogger logger, string skillFilePath, string skillName, string directoryName);
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")]
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath);
@@ -1,136 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class ChatHistoryCompactionPipelineTests
{
[Fact]
public async Task EmptyStrategies_ReturnsUnmodifiedAsync()
{
// Arrange
ChatHistoryCompactionPipeline pipeline = new([]);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
// Assert
Assert.False(result.AnyApplied);
Assert.Equal(1, result.Before.MessageCount);
Assert.Equal(1, result.After.MessageCount);
Assert.Empty(result.StrategyResults);
}
[Fact]
public async Task ChainsStrategies_InOrderAsync()
{
// Arrange
ChatHistoryCompactionStrategy[] strategies =
[
new NeverCompactStrategy(),
new RemoveFirstMessageStrategy(),
];
ChatHistoryCompactionPipeline pipeline = new(strategies);
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
];
// Act
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
// Assert
Assert.True(result.AnyApplied);
Assert.Equal(2, result.StrategyResults.Count);
Assert.False(result.StrategyResults[0].Applied);
Assert.True(result.StrategyResults[1].Applied);
Assert.Single(messages);
}
[Fact]
public async Task ReportsOverallMetricsAsync()
{
// Arrange
ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
new(ChatRole.User, "Third"),
];
// Act
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
// Assert
Assert.Equal(3, result.Before.MessageCount);
Assert.Equal(2, result.After.MessageCount);
}
[Fact]
public async Task CustomMetricsCalculator_IsUsedAsync()
{
// Arrange
Moq.Mock<IChatHistoryMetricsCalculator> calcMock = new();
calcMock
.Setup(c => c.Calculate(Moq.It.IsAny<IReadOnlyList<ChatMessage>>()))
.Returns(new ChatHistoryMetric { MessageCount = 42 });
ChatHistoryCompactionPipeline pipeline = new(calcMock.Object, []);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
// Assert
Assert.Equal(42, result.Before.MessageCount);
calcMock.Verify(c => c.Calculate(Moq.It.IsAny<IReadOnlyList<ChatMessage>>()), Moq.Times.Once);
}
[Fact]
public async Task ReduceAsync_DelegatesCompactionAsync()
{
// Arrange
ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
new(ChatRole.User, "Third"),
];
// Act
IEnumerable<ChatMessage> result = await pipeline.ReduceAsync(messages, default);
List<ChatMessage> resultList = result.ToList();
// Assert
Assert.Equal(2, resultList.Count);
Assert.Equal("Second", resultList[0].Text);
Assert.Equal("Third", resultList[1].Text);
}
[Fact]
public async Task ReduceAsync_EmptyStrategies_ReturnsAllMessagesAsync()
{
// Arrange
ChatHistoryCompactionPipeline pipeline = new([]);
ChatMessage[] messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.User, "World"),
];
// Act
IEnumerable<ChatMessage> result = await pipeline.ReduceAsync(messages, default);
// Assert
Assert.Equal(2, result.Count());
}
}
@@ -1,144 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class ChatHistoryCompactionStrategyTests
{
[Fact]
public async Task ShouldCompactReturnsFalse_SkipsAsync()
{
// Arrange
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
NeverCompactStrategy strategy = new();
// Act
CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
// Assert
Assert.False(result.Applied);
}
[Fact]
public async Task ShouldCompactReturnsTrue_RunsCompactionAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
];
RemoveFirstMessageStrategy strategy = new();
// Act
CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
// Assert
Assert.True(result.Applied);
Assert.Single(messages);
Assert.Equal("Second", messages[0].Text);
Assert.Equal(2, result.Before.MessageCount);
Assert.Equal(1, result.After.MessageCount);
}
[Fact]
public async Task DelegatesToReducerAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
];
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> messages, CancellationToken _) => messages.Skip(1));
TestCompactionStrategy strategy = new(reducerMock.Object);
// Act
CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
// Assert
Assert.True(result.Applied);
Assert.Single(messages);
Assert.Equal("Second", messages[0].Text);
reducerMock.Verify(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ReducerNoChange_ReturnsFalseAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
];
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs);
TestCompactionStrategy strategy = new(reducerMock.Object, shouldCompact: false);
// Act
CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
// Assert
Assert.False(result.Applied);
Assert.Single(messages);
}
[Fact]
public void ReducerLifecycle()
{
// Arrange
Mock<IChatReducer> reducerMock = new();
// Act
TestCompactionStrategy strategy = new(reducerMock.Object);
// Assert
Assert.Same(reducerMock.Object, strategy.Reducer);
Assert.NotNull(strategy.Name);
Assert.NotEmpty(strategy.Name);
Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name);
}
[Fact]
public void CurrentMetrics_OutsideStrategy_Throws()
{
// Act & Assert
Assert.Throws<InvalidOperationException>(() => TestCompactionStrategy.GetCurrentMetrics());
}
public static async ValueTask<CompactionResult> RunCompactionStrategyAsync(ChatHistoryCompactionStrategy strategy, List<ChatMessage> messages)
{
// Act
ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages);
return await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance);
}
private sealed class TestCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly bool _shouldCompact;
public TestCompactionStrategy(IChatReducer reducer, bool shouldCompact = true)
: base(reducer)
{
this._shouldCompact = shouldCompact;
}
protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._shouldCompact;
public static ChatHistoryMetric GetCurrentMetrics() => CurrentMetrics;
}
}
@@ -1,114 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class ChatReducerCompactionStrategyTests : CompactionStrategyTestBase
{
[Fact]
public async Task ConditionFalse_SkipsAsync()
{
// Arrange
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
Mock<IChatReducer> reducerMock = new();
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => false);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
// Assert
reducerMock.Verify(
r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ConditionTrue_RunsReducerAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
];
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs.Skip(1));
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1);
// Assert
Assert.Equal("Second", messages[0].Text);
reducerMock.Verify(
r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task ConditionReceivesMetricsAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
ChatHistoryMetric? capturedMetrics = null;
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs);
ChatReducerCompactionStrategy strategy = new(
reducerMock.Object,
metrics =>
{
capturedMetrics = metrics;
return false;
});
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
// Assert
Assert.NotNull(capturedMetrics);
Assert.Equal(2, capturedMetrics!.MessageCount);
}
[Fact]
public async Task ReducerNoChange_AppliedFalseAsync()
{
// Arrange
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs);
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public void Name_ReturnsReducerTypeName()
{
// Arrange
Mock<IChatReducer> reducerMock = new();
// Act
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true);
// Assert
Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name);
}
}
@@ -1,44 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Compaction;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class CompactionMetricTests
{
[Fact]
public void DefaultValues_AreZero()
{
// Arrange & Act
ChatHistoryMetric metrics = new();
// Assert
Assert.Equal(0, metrics.TokenCount);
Assert.Equal(0L, metrics.ByteCount);
Assert.Equal(0, metrics.MessageCount);
Assert.Equal(0, metrics.ToolCallCount);
Assert.Equal(0, metrics.UserTurnCount);
Assert.Empty(metrics.Groups);
}
[Fact]
public void InitProperties_SetCorrectly()
{
// Arrange & Act
ChatHistoryMetric metrics = new()
{
TokenCount = 100,
ByteCount = 500,
MessageCount = 5,
ToolCallCount = 2,
UserTurnCount = 3
};
// Assert
Assert.Equal(100, metrics.TokenCount);
Assert.Equal(500L, metrics.ByteCount);
Assert.Equal(5, metrics.MessageCount);
Assert.Equal(2, metrics.ToolCallCount);
Assert.Equal(3, metrics.UserTurnCount);
}
}
@@ -1,57 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Compaction;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class CompactionPipelineResultTests
{
[Fact]
public void Properties_AreReadable()
{
// Arrange
ChatHistoryMetric before = new() { MessageCount = 10 };
ChatHistoryMetric after = new() { MessageCount = 5 };
CompactionResult strategyResult = new("Test", applied: true, before, after);
List<CompactionResult> results = [strategyResult];
// Act
CompactionPipelineResult pipelineResult = new(before, after, results);
// Assert
Assert.Same(before, pipelineResult.Before);
Assert.Same(after, pipelineResult.After);
Assert.Single(pipelineResult.StrategyResults);
}
[Fact]
public void AnyApplied_AllFalse_ReturnsFalse()
{
// Arrange
ChatHistoryMetric metrics = new() { MessageCount = 5 };
CompactionResult skipped = CompactionResult.Skipped("Skip", metrics);
// Act
CompactionPipelineResult result = new(metrics, metrics, [skipped]);
// Assert
Assert.False(result.AnyApplied);
}
[Fact]
public void AnyApplied_SomeTrue_ReturnsTrue()
{
// Arrange
ChatHistoryMetric before = new() { MessageCount = 10 };
ChatHistoryMetric after = new() { MessageCount = 5 };
CompactionResult applied = new("Applied", applied: true, before, after);
// Act
CompactionPipelineResult result = new(before, after, [applied]);
// Assert
Assert.True(result.AnyApplied);
}
}
@@ -1,24 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Compaction;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class CompactionResultTests
{
[Fact]
public void Skipped_HasSameBeforeAndAfter()
{
// Arrange
ChatHistoryMetric metrics = new() { MessageCount = 5, TokenCount = 100 };
// Act
CompactionResult result = CompactionResult.Skipped("Test", metrics);
// Assert
Assert.Equal("Test", result.StrategyName);
Assert.False(result.Applied);
Assert.Same(metrics, result.Before);
Assert.Same(metrics, result.After);
}
}
@@ -1,40 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public abstract class CompactionStrategyTestBase
{
public static async ValueTask<CompactionResult> RunCompactionStrategyReducedAsync(ChatHistoryCompactionStrategy strategy, List<ChatMessage> messages, int expectedCount)
{
// Act
ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages);
CompactionResult result = await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance);
// Assert
Assert.True(result.Applied);
Assert.NotEqual(result.Before, result.After);
Assert.Equal(expectedCount, messages.Count);
return result;
}
public static async ValueTask<CompactionResult> RunCompactionStrategySkippedAsync(ChatHistoryCompactionStrategy strategy, List<ChatMessage> messages)
{
// Act
int initialCount = messages.Count;
ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages);
CompactionResult result = await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance);
// Assert
Assert.False(result.Applied);
Assert.Equal(result.Before, result.After);
Assert.Equal(initialCount, messages.Count);
return result;
}
}
@@ -1,399 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class DefaultChatHistoryMetricsCalculatorTests
{
[Fact]
public void EmptyList_ReturnsZeroMetrics()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
// Act
ChatHistoryMetric metrics = calculator.Calculate([]);
// Assert
Assert.Equal(0, metrics.TokenCount);
Assert.Equal(0L, metrics.ByteCount);
Assert.Equal(0, metrics.MessageCount);
Assert.Equal(0, metrics.ToolCallCount);
Assert.Equal(0, metrics.UserTurnCount);
}
[Fact]
public void CountsMessages()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there"),
];
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(2, metrics.MessageCount);
}
[Fact]
public void CountsUserTurns()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
new(ChatRole.User, "How are you?"),
new(ChatRole.Assistant, "Good"),
];
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(2, metrics.UserTurnCount);
}
[Fact]
public void CountsToolCalls()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
ChatMessage assistantMsg = new(ChatRole.Assistant, [
new FunctionCallContent("call1", "get_weather", new Dictionary<string, object?> { ["city"] = "NYC" }),
new FunctionCallContent("call2", "get_time"),
]);
List<ChatMessage> messages =
[
new(ChatRole.User, "What's the weather?"),
assistantMsg,
];
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(2, metrics.ToolCallCount);
}
[Fact]
public void ConsecutiveUserMessages_CountAsOneTurn()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
new(ChatRole.Assistant, "Reply"),
];
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(1, metrics.UserTurnCount);
}
[Fact]
public void TokenCount_IsPositive()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello world"),
];
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.True(metrics.TokenCount > 0);
Assert.True(metrics.ByteCount > 0);
}
[Fact]
public void NullInput_ReturnsZeroMetrics()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
// Act
ChatHistoryMetric metrics = calculator.Calculate(null!);
// Assert
Assert.Equal(0, metrics.TokenCount);
Assert.Equal(0L, metrics.ByteCount);
Assert.Equal(0, metrics.MessageCount);
Assert.Empty(metrics.Groups);
}
[Fact]
public void InvalidCharsPerToken_UsesDefault()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new(charsPerToken: 0);
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello world"),
];
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.True(metrics.TokenCount > 0);
}
[Fact]
public void NullMessageText_HandledGracefully()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
ChatMessage msg = new() { Role = ChatRole.User };
List<ChatMessage> messages = [msg];
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(1, metrics.MessageCount);
Assert.True(metrics.TokenCount > 0);
Assert.Equal(0L, metrics.ByteCount);
}
[Fact]
public void NullContents_SkipsToolCounting()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
ChatMessage msg = new(ChatRole.User, "text");
msg.Contents = null!;
List<ChatMessage> messages = [msg];
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(1, metrics.MessageCount);
Assert.Equal(0, metrics.ToolCallCount);
}
[Fact]
public void MessageWithOnlyNonTextContent_NullTextHandled()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
ChatMessage msg = new(ChatRole.Assistant,
[
new FunctionCallContent("c1", "func"),
]);
List<ChatMessage> messages = [msg];
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(1, metrics.MessageCount);
Assert.Equal(1, metrics.ToolCallCount);
}
[Fact]
public void Calculate_PopulatesGroupIndex()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new(ChatRole.System, "System prompt"),
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there"),
];
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(3, metrics.Groups.Count);
Assert.Equal(ChatMessageGroupKind.System, metrics.Groups[0].Kind);
Assert.Equal(ChatMessageGroupKind.UserTurn, metrics.Groups[1].Kind);
Assert.Equal(ChatMessageGroupKind.AssistantPlain, metrics.Groups[2].Kind);
}
[Fact]
public void EmptyList_GroupIndexIsEmpty()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
// Act
ChatHistoryMetric metrics = calculator.Calculate([]);
// Assert
Assert.Empty(metrics.Groups);
}
[Fact]
public void GroupIndex_SystemMessage_IdentifiedCorrectly()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helpful assistant"),
];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Single(groups);
Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind);
Assert.Equal(0, groups[0].StartIndex);
Assert.Equal(1, groups[0].Count);
}
[Fact]
public void GroupIndex_AssistantWithToolCalls_GroupedWithResults()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
ChatMessage assistantMsg = new(ChatRole.Assistant, [
new FunctionCallContent("call1", "get_weather", new Dictionary<string, object?> { ["city"] = "NYC" }),
]);
ChatMessage toolResult = new(ChatRole.Tool, [
new FunctionResultContent("call1", "Sunny, 72°F"),
]);
List<ChatMessage> messages =
[
new(ChatRole.User, "What's the weather?"),
assistantMsg,
toolResult,
];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Equal(2, groups.Count);
Assert.Equal(ChatMessageGroupKind.UserTurn, groups[0].Kind);
Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[1].Kind);
Assert.Equal(1, groups[1].StartIndex);
Assert.Equal(2, groups[1].Count);
}
[Fact]
public void GroupIndex_MultipleToolResults_GroupedTogether()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
ChatMessage assistantMsg = new(ChatRole.Assistant, [
new FunctionCallContent("c1", "func1"),
new FunctionCallContent("c2", "func2"),
]);
ChatMessage tool1 = new(ChatRole.Tool, [new FunctionResultContent("c1", "result1")]);
ChatMessage tool2 = new(ChatRole.Tool, [new FunctionResultContent("c2", "result2")]);
List<ChatMessage> messages = [assistantMsg, tool1, tool2];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Single(groups);
Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[0].Kind);
Assert.Equal(3, groups[0].Count);
}
[Fact]
public void GroupIndex_ComplexConversation_CorrectGrouping()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helper"),
new(ChatRole.User, "Hi"),
new(ChatRole.Assistant, "Hello!"),
new(ChatRole.User, "Get weather"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
new(ChatRole.Assistant, "It's sunny!"),
];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Equal(6, groups.Count);
Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind);
Assert.Equal(ChatMessageGroupKind.UserTurn, groups[1].Kind);
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[2].Kind);
Assert.Equal(ChatMessageGroupKind.UserTurn, groups[3].Kind);
Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[4].Kind);
Assert.Equal(2, groups[4].Count);
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[5].Kind);
}
[Fact]
public void GroupIndex_OrphanToolResult_IdentifiedCorrectly()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "orphan result")]),
];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Single(groups);
Assert.Equal(ChatMessageGroupKind.ToolResult, groups[0].Kind);
}
[Fact]
public void GroupIndex_UnknownRole_IdentifiedAsOther()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new(new ChatRole("custom"), "custom message"),
];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Single(groups);
Assert.Equal(ChatMessageGroupKind.Other, groups[0].Kind);
}
[Fact]
public void GroupIndex_AssistantWithNullContents_ClassifiedAsPlain()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
ChatMessage msg = new(ChatRole.Assistant, "reply");
msg.Contents = null!;
List<ChatMessage> messages = [msg];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Single(groups);
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[0].Kind);
}
}
@@ -1,52 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
/// <summary>
/// Provides a way to set <see cref="AIAgent.CurrentRunContext"/> in unit tests
/// so that the underlying <c>AsyncLocal</c> is populated for code that reads it.
/// </summary>
internal static class AgentRunContextHarness
{
private static readonly ContextAgentShim s_instance = new();
/// <summary>
/// Sets <see cref="AIAgent.CurrentRunContext"/> and invokes the provided action.
/// </summary>
public static void ExecuteWithRunContext(AgentRunContext context, Action action)
{
Assert.NotNull(context);
Assert.NotNull(action);
//AgentRunContext context = new(agent, session, messages ?? [], options); // %%% TODO
s_instance.Set(context);
action.Invoke();
}
// Derived class that exposes the protected setter.
private sealed class ContextAgentShim : AIAgent
{
public void Set(AgentRunContext? value) => CurrentRunContext = value;
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
@@ -1,27 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
internal sealed class NeverCompactStrategy : ChatHistoryCompactionStrategy
{
public NeverCompactStrategy()
: base(new NoOpReducer())
{
}
public override string Name => "NeverCompact";
protected override bool ShouldCompact(ChatHistoryMetric metrics) => false;
private sealed class NoOpReducer : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
=> Task.FromResult(messages);
}
}
@@ -1,36 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
internal sealed class RemoveFirstMessageStrategy : ChatHistoryCompactionStrategy
{
public RemoveFirstMessageStrategy()
: base(new RemoveFirstReducer())
{
}
public override string Name => "RemoveFirst";
protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.MessageCount > 0;
private sealed class RemoveFirstReducer : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
{
List<ChatMessage> list = messages.ToList();
if (list.Count > 1)
{
list.RemoveAt(0);
}
return Task.FromResult<IEnumerable<ChatMessage>>(list);
}
}
}
@@ -1,65 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Compaction;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class MessageGroupTests
{
[Fact]
public void Equality_Works()
{
// Arrange
ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
ChatMessageGroup c = new(1, 2, ChatMessageGroupKind.AssistantToolGroup);
// Act & Assert
Assert.Equal(a, b);
Assert.True(a == b);
Assert.NotEqual(a, c);
Assert.True(a != c);
}
[Fact]
public void Equals_Object_NullReturnsFalse()
{
// Arrange
ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System);
// Act & Assert
Assert.False(group.Equals(null));
}
[Fact]
public void Equals_Object_BoxedMessageGroupReturnsTrue()
{
// Arrange
ChatMessageGroup group = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
object boxed = new ChatMessageGroup(0, 2, ChatMessageGroupKind.AssistantToolGroup);
// Act & Assert
Assert.True(group.Equals(boxed));
}
[Fact]
public void Equals_Object_WrongTypeReturnsFalse()
{
// Arrange
ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System);
// Act & Assert
Assert.False(group.Equals("not a MessageGroup"));
}
[Fact]
public void GetHashCode_ConsistentForEqualInstances()
{
// Arrange
ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
// Act & Assert
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
}
@@ -1,135 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class SlidingWindowCompactionStrategyTests : CompactionStrategyTestBase
{
[Fact]
public async Task UnderLimit_NoChangeAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
SlidingWindowCompactionStrategy strategy = new(maxTurns: 10);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task KeepsLastNTurnsAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Turn 1"),
new(ChatRole.Assistant, "Reply 1"),
new(ChatRole.User, "Turn 2"),
new(ChatRole.Assistant, "Reply 2"),
new(ChatRole.User, "Turn 3"),
new(ChatRole.Assistant, "Reply 3"),
];
SlidingWindowCompactionStrategy strategy = new(maxTurns: 2);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
// Assert
Assert.Equal("Turn 2", messages[0].Text);
Assert.Equal("Reply 2", messages[1].Text);
Assert.Equal("Turn 3", messages[2].Text);
Assert.Equal("Reply 3", messages[3].Text);
}
[Fact]
public async Task PreservesSystemMessagesAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helper"),
new(ChatRole.User, "Turn 1"),
new(ChatRole.Assistant, "Reply 1"),
new(ChatRole.User, "Turn 2"),
new(ChatRole.Assistant, "Reply 2"),
];
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3);
// Assert
Assert.Equal(ChatRole.System, messages[0].Role);
Assert.Equal("You are a helper", messages[0].Text);
Assert.Equal("Turn 2", messages[1].Text);
Assert.Equal("Reply 2", messages[2].Text);
}
[Fact]
public async Task PreservesToolGroupsWithinKeptTurnsAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Turn 1"),
new(ChatRole.Assistant, "Reply 1"),
new(ChatRole.User, "Get weather"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
new(ChatRole.Assistant, "It's sunny!"),
];
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
// Assert
Assert.Equal("Get weather", messages[0].Text);
}
[Fact]
public async Task SingleTurn_AtLimit_NoChangeAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task DropsResponseGroupsFromOldTurnsAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Turn 1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "search")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "result")]),
new(ChatRole.Assistant, "Here's what I found"),
new(ChatRole.User, "Turn 2"),
new(ChatRole.Assistant, "Reply 2"),
];
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
// Assert
Assert.Equal("Turn 2", messages[0].Text);
Assert.Equal("Reply 2", messages[1].Text);
}
}
@@ -1,164 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class SummarizationCompactionStrategyTests : CompactionStrategyTestBase
{
[Fact]
public async Task UnderLimit_NoChangeAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
Mock<IChatClient> chatClientMock = new();
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
// Assert
chatClientMock.Verify(
c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task SummarizesOldGroupsAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "What's the weather?"),
new(ChatRole.Assistant, "The weather is sunny and 72°F."),
new(ChatRole.User, "How about tomorrow?"),
new(ChatRole.Assistant, "Tomorrow will be cloudy."),
new(ChatRole.User, "Thanks!"),
new(ChatRole.Assistant, "You're welcome!"),
];
Mock<IChatClient> chatClientMock = new();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "User asked about weather. It was sunny.")));
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 2);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3);
// Assert
Assert.Contains("[Summary]", messages[0].Text);
Assert.Contains("sunny", messages[0].Text);
Assert.Equal("Thanks!", messages[1].Text);
Assert.Equal("You're welcome!", messages[2].Text);
}
[Fact]
public async Task PreservesSystemMessagesAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helper"),
new(ChatRole.User, "Turn 1"),
new(ChatRole.Assistant, "Reply 1"),
new(ChatRole.User, "Turn 2"),
new(ChatRole.Assistant, "Reply 2"),
];
Mock<IChatClient> chatClientMock = new();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of earlier discussion.")));
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3);
// Assert
Assert.Equal(ChatRole.System, messages[0].Role);
Assert.Equal("You are a helper", messages[0].Text);
Assert.Contains("[Summary]", messages[1].Text);
}
[Fact]
public async Task AllGroupsProtected_NoChangeAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
Mock<IChatClient> chatClientMock = new();
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
// Assert
chatClientMock.Verify(
c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task CustomPrompt_UsedInRequestAsync()
{
// Arrange
const string CustomPrompt = "Summarize briefly.";
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.Assistant, "Reply"),
new(ChatRole.User, "Second"),
new(ChatRole.Assistant, "Reply 2"),
];
List<ChatMessage>? capturedMessages = null;
Mock<IChatClient> chatClientMock = new();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, _, _) => capturedMessages = [.. msgs])
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Brief summary.")));
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1, summarizationPrompt: CustomPrompt);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
// Assert
Assert.NotNull(capturedMessages);
Assert.Equal(ChatRole.System, capturedMessages![0].Role);
Assert.Equal(CustomPrompt, capturedMessages[0].Text);
}
[Fact]
public async Task NullResponseText_UsesFallbackAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.Assistant, "Reply"),
new(ChatRole.User, "Second"),
new(ChatRole.Assistant, "Reply 2"),
];
Mock<IChatClient> chatClientMock = new();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, (string?)null)));
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
// Assert
Assert.Contains("[Summary unavailable]", messages[0].Text);
}
}
@@ -1,131 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class ToolResultCompactionStrategyTests : CompactionStrategyTestBase
{
[Fact]
public async Task UnderLimit_NoChangeAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
];
ToolResultCompactionStrategy strategy = new(maxTokens: 100000);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task CollapsesOldToolGroupAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Check weather"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny, 72°F")]),
new(ChatRole.User, "Thanks"),
new(ChatRole.Assistant, "You're welcome!"),
];
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
// Assert
Assert.Contains("[Tool calls: get_weather]", messages[1].Text);
Assert.Equal(ChatRole.Assistant, messages[1].Role);
}
[Fact]
public async Task ProtectsRecentGroupsAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Check weather"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
new(ChatRole.User, "Thanks"),
new(ChatRole.Assistant, "You're welcome!"),
];
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task PreservesSystemMessagesAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helper"),
new(ChatRole.User, "Check weather"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
new(ChatRole.User, "Thanks"),
new(ChatRole.Assistant, "You're welcome!"),
];
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 5);
// Assert
Assert.Equal(ChatRole.System, messages[0].Role);
Assert.Equal("You are a helper", messages[0].Text);
}
[Fact]
public async Task MultipleToolCalls_ListedInSummaryAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Do research"),
new ChatMessage(ChatRole.Assistant, [
new FunctionCallContent("c1", "search"),
new FunctionCallContent("c2", "fetch_page"),
]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "results...")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "page content...")]),
new(ChatRole.User, "Summarize"),
new(ChatRole.Assistant, "Here's the summary."),
];
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
// Assert
Assert.Contains("search", messages[1].Text);
Assert.Contains("fetch_page", messages[1].Text);
}
[Fact]
public async Task NoToolGroups_NoChangeAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there"),
];
ToolResultCompactionStrategy strategy = new(maxTokens: 1);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
}
@@ -1,93 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class TruncationCompactionStrategyTests : CompactionStrategyTestBase
{
[Fact]
public async Task UnderLimit_NoChangeAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
];
TruncationCompactionStrategy strategy = new(maxTokens: 100000);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task OverLimit_RemovesOldestGroupsAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "First message"),
new(ChatRole.Assistant, "First reply"),
new(ChatRole.User, "Second message"),
new(ChatRole.Assistant, "Second reply"),
];
TruncationCompactionStrategy strategy = new(maxTokens: 1);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1);
}
[Fact]
public async Task SystemOnlyMessages_NoChangeAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helper"),
];
TruncationCompactionStrategy strategy = new(maxTokens: 1);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task SingleNonSystemGroup_NoChangeAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.System, "System prompt"),
new(ChatRole.User, "Only user message"),
];
TruncationCompactionStrategy strategy = new(maxTokens: 1);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task PreserveRecentGroups_KeepsMultipleGroupsAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Turn 1"),
new(ChatRole.Assistant, "Reply 1"),
new(ChatRole.User, "Turn 2"),
new(ChatRole.Assistant, "Reply 2"),
new(ChatRole.User, "Turn 3"),
new(ChatRole.Assistant, "Reply 3"),
];
TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
// Assert
Assert.Equal("Reply 3", messages[^1].Text);
}
}
@@ -105,7 +105,7 @@ public class AgentHostingServiceCollectionExtensionsTests
}
/// <summary>
/// Verifies that AddAIAgent registers the agent as a keyed singleton service.
/// Verifies that AddAIAgent registers the agent as a keyed singleton service by default.
/// </summary>
[Fact]
public void AddAIAgent_RegistersKeyedSingleton()
@@ -203,4 +203,94 @@ public class AgentHostingServiceCollectionExtensionsTests
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
}
/// <summary>
/// Verifies that AddAIAgent registers with the specified scoped lifetime.
/// </summary>
[Fact]
public void AddAIAgent_WithScopedLifetime_RegistersKeyedScoped()
{
// Arrange
var services = new ServiceCollection();
var mockAgent = new Mock<AIAgent>();
const string AgentName = "scopedAgent";
// Act
var result = services.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Scoped);
// Assert
var descriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == AgentName &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
Assert.Equal(ServiceLifetime.Scoped, result.Lifetime);
}
/// <summary>
/// Verifies that AddAIAgent registers with the specified transient lifetime.
/// </summary>
[Fact]
public void AddAIAgent_WithTransientLifetime_RegistersKeyedTransient()
{
// Arrange
var services = new ServiceCollection();
var mockAgent = new Mock<AIAgent>();
const string AgentName = "transientAgent";
// Act
var result = services.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Transient);
// Assert
var descriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == AgentName &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
Assert.Equal(ServiceLifetime.Transient, result.Lifetime);
}
/// <summary>
/// Verifies that the builder exposes the correct lifetime for default registration.
/// </summary>
[Fact]
public void AddAIAgent_DefaultLifetime_BuilderExposesSingleton()
{
// Arrange
var services = new ServiceCollection();
var mockAgent = new Mock<AIAgent>();
// Act
var result = services.AddAIAgent("agentName", (sp, key) => mockAgent.Object);
// Assert
Assert.Equal(ServiceLifetime.Singleton, result.Lifetime);
}
/// <summary>
/// Verifies that AddAIAgent with instructions overload respects the lifetime parameter.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
public void AddAIAgent_InstructionsOverload_RespectsLifetime(ServiceLifetime lifetime)
{
// Arrange
var services = new ServiceCollection();
// Act
var result = services.AddAIAgent("agent", "instructions", lifetime);
// Assert
var descriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == "agent" &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(lifetime, descriptor.Lifetime);
Assert.Equal(lifetime, result.Lifetime);
}
}
@@ -127,7 +127,7 @@ public class HostApplicationBuilderAgentExtensionsTests
}
/// <summary>
/// Verifies that AddAIAgent registers the agent as a keyed singleton service.
/// Verifies that AddAIAgent registers the agent as a keyed singleton service by default.
/// </summary>
[Fact]
public void AddAIAgent_RegistersKeyedSingleton()
@@ -235,4 +235,77 @@ public class HostApplicationBuilderAgentExtensionsTests
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
}
/// <summary>
/// Verifies that AddAIAgent registers with the specified scoped lifetime via the host builder.
/// </summary>
[Fact]
public void AddAIAgent_WithScopedLifetime_RegistersKeyedScoped()
{
// Arrange
var builder = new HostApplicationBuilder();
var mockAgent = new Mock<AIAgent>();
const string AgentName = "scopedAgent";
// Act
var result = builder.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Scoped);
// Assert
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == AgentName &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
Assert.Equal(ServiceLifetime.Scoped, result.Lifetime);
}
/// <summary>
/// Verifies that AddAIAgent registers with the specified transient lifetime via the host builder.
/// </summary>
[Fact]
public void AddAIAgent_WithTransientLifetime_RegistersKeyedTransient()
{
// Arrange
var builder = new HostApplicationBuilder();
var mockAgent = new Mock<AIAgent>();
const string AgentName = "transientAgent";
// Act
var result = builder.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Transient);
// Assert
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == AgentName &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
Assert.Equal(ServiceLifetime.Transient, result.Lifetime);
}
/// <summary>
/// Verifies that AddAIAgent with instructions overload respects the lifetime parameter via the host builder.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
public void AddAIAgent_InstructionsOverload_RespectsLifetime(ServiceLifetime lifetime)
{
// Arrange
var builder = new HostApplicationBuilder();
// Act
var result = builder.AddAIAgent("agent", "instructions", lifetime);
// Assert
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == "agent" &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(lifetime, descriptor.Lifetime);
Assert.Equal(lifetime, result.Lifetime);
}
}
@@ -63,7 +63,7 @@ public class HostApplicationBuilderWorkflowExtensionsTests
}
/// <summary>
/// Verifies that AddWorkflow registers the workflow as a keyed singleton service.
/// Verifies that AddWorkflow registers the workflow as a keyed singleton service by default.
/// </summary>
[Fact]
public void AddWorkflow_RegistersKeyedSingleton()
@@ -328,6 +328,77 @@ public class HostApplicationBuilderWorkflowExtensionsTests
Assert.NotNull(agentDescriptor);
}
/// <summary>
/// Verifies that AddWorkflow registers with the specified scoped lifetime.
/// </summary>
[Fact]
public void AddWorkflow_WithScopedLifetime_RegistersKeyedScoped()
{
// Arrange
var builder = new HostApplicationBuilder();
const string WorkflowName = "scopedWorkflow";
// Act
builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key), ServiceLifetime.Scoped);
// Assert
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == WorkflowName &&
d.ServiceType == typeof(Workflow));
Assert.NotNull(descriptor);
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
}
/// <summary>
/// Verifies that AddWorkflow registers with the specified transient lifetime.
/// </summary>
[Fact]
public void AddWorkflow_WithTransientLifetime_RegistersKeyedTransient()
{
// Arrange
var builder = new HostApplicationBuilder();
const string WorkflowName = "transientWorkflow";
// Act
builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key), ServiceLifetime.Transient);
// Assert
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == WorkflowName &&
d.ServiceType == typeof(Workflow));
Assert.NotNull(descriptor);
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
}
/// <summary>
/// Verifies that AddAsAIAgent respects the lifetime parameter.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
public void AddAsAIAgent_RespectsLifetime(ServiceLifetime lifetime)
{
// Arrange
var builder = new HostApplicationBuilder();
const string WorkflowName = "testWorkflow";
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
// Act
var agentBuilder = workflowBuilder.AddAsAIAgent("agent", lifetime);
// Assert
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == "agent" &&
d.ServiceType == typeof(AIAgent));
Assert.NotNull(descriptor);
Assert.Equal(lifetime, descriptor.Lifetime);
Assert.Equal(lifetime, agentBuilder.Lifetime);
}
/// <summary>
/// Helper method to create a simple test workflow with a given name.
/// </summary>
@@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
@@ -250,6 +251,179 @@ public sealed class HostedAgentBuilderToolsExtensionsTests
Assert.Contains(factoryTool, agentTools);
}
/// <summary>
/// Verifies that WithAITool factory method defaults to the agent's lifetime when no explicit lifetime is specified.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
public void WithAIToolFactory_DefaultsToAgentLifetime(ServiceLifetime agentLifetime)
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, agentLifetime);
// Act
builder.WithAITool(_ => new DummyAITool());
// Assert
var toolDescriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == "test-agent" &&
d.ServiceType == typeof(AITool));
Assert.NotNull(toolDescriptor);
Assert.Equal(agentLifetime, toolDescriptor.Lifetime);
}
/// <summary>
/// Verifies that WithAITool factory method accepts an explicit lifetime override.
/// </summary>
[Fact]
public void WithAIToolFactory_ExplicitLifetimeOverridesDefault()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Transient);
// Act - Transient agent with Singleton tool is valid (longer-lived dependency)
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Singleton);
// Assert
var toolDescriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == "test-agent" &&
d.ServiceType == typeof(AITool));
Assert.NotNull(toolDescriptor);
Assert.Equal(ServiceLifetime.Singleton, toolDescriptor.Lifetime);
}
/// <summary>
/// Verifies that WithAITool factory throws for singleton agent with scoped tool (captive dependency).
/// </summary>
[Fact]
public void WithAIToolFactory_SingletonAgentWithScopedTool_ThrowsInvalidOperationException()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Singleton);
// Act & Assert
Assert.Throws<InvalidOperationException>(() =>
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Scoped));
}
/// <summary>
/// Verifies that WithAITool factory throws for singleton agent with transient tool (captive dependency).
/// </summary>
[Fact]
public void WithAIToolFactory_SingletonAgentWithTransientTool_ThrowsInvalidOperationException()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Singleton);
// Act & Assert
Assert.Throws<InvalidOperationException>(() =>
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Transient));
}
/// <summary>
/// Verifies that WithAITool factory throws for scoped agent with transient tool (captive dependency).
/// </summary>
[Fact]
public void WithAIToolFactory_ScopedAgentWithTransientTool_ThrowsInvalidOperationException()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Scoped);
// Act & Assert
Assert.Throws<InvalidOperationException>(() =>
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Transient));
}
/// <summary>
/// Verifies all valid tool lifetime combinations do not throw.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton, ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped, ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped, ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient, ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Transient, ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient, ServiceLifetime.Transient)]
public void WithAIToolFactory_ValidLifetimeCombinations_DoNotThrow(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, agentLifetime);
// Act & Assert - should not throw
builder.WithAITool(_ => new DummyAITool(), toolLifetime);
}
/// <summary>
/// Verifies that ValidateToolLifetime correctly identifies all invalid combinations.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton, ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Singleton, ServiceLifetime.Transient)]
[InlineData(ServiceLifetime.Scoped, ServiceLifetime.Transient)]
public void ValidateToolLifetime_InvalidCombinations_Throw(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
{
// Act & Assert
Assert.Throws<InvalidOperationException>(() =>
HostedAgentBuilderExtensions.ValidateToolLifetime(agentLifetime, toolLifetime));
}
/// <summary>
/// Verifies that the WithSessionStore factory method defaults to Singleton regardless of agent lifetime.
/// </summary>
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
public void WithSessionStoreFactory_DefaultsToSingleton(ServiceLifetime agentLifetime)
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, agentLifetime);
// Act
builder.WithSessionStore((sp, name) => new InMemoryAgentSessionStore());
// Assert
var storeDescriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == "test-agent" &&
d.ServiceType == typeof(AgentSessionStore));
Assert.NotNull(storeDescriptor);
Assert.Equal(ServiceLifetime.Singleton, storeDescriptor.Lifetime);
}
/// <summary>
/// Verifies that the WithSessionStore factory method accepts an explicit lifetime override.
/// </summary>
[Fact]
public void WithSessionStoreFactory_ExplicitLifetimeOverridesDefault()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Transient);
// Act
builder.WithSessionStore((sp, name) => new InMemoryAgentSessionStore(), ServiceLifetime.Singleton);
// Assert
var storeDescriptor = services.FirstOrDefault(
d => (d.ServiceKey as string) == "test-agent" &&
d.ServiceType == typeof(AgentSessionStore));
Assert.NotNull(storeDescriptor);
Assert.Equal(ServiceLifetime.Singleton, storeDescriptor.Lifetime);
}
/// <summary>
/// Dummy AITool implementation for testing.
/// </summary>
@@ -122,10 +122,11 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[InlineData("-leading-hyphen")]
[InlineData("trailing-hyphen-")]
[InlineData("has spaces")]
[InlineData("consecutive--hyphens")]
public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName)
{
// Arrange
string skillDir = Path.Combine(this._testRoot, "invalid-name-test");
string skillDir = Path.Combine(this._testRoot, invalidName);
if (Directory.Exists(skillDir))
{
Directory.Delete(skillDir, recursive: true);
@@ -147,15 +148,19 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly()
{
// Arrange
string dir1 = Path.Combine(this._testRoot, "skill-a");
string dir2 = Path.Combine(this._testRoot, "skill-b");
string dir1 = Path.Combine(this._testRoot, "dupe");
string dir2 = Path.Combine(this._testRoot, "subdir");
Directory.CreateDirectory(dir1);
Directory.CreateDirectory(dir2);
// Create a nested duplicate: subdir/dupe/SKILL.md
string nestedDir = Path.Combine(dir2, "dupe");
Directory.CreateDirectory(nestedDir);
File.WriteAllText(
Path.Combine(dir1, "SKILL.md"),
"---\nname: dupe\ndescription: First\n---\nFirst body.");
File.WriteAllText(
Path.Combine(dir2, "SKILL.md"),
Path.Combine(nestedDir, "SKILL.md"),
"---\nname: dupe\ndescription: Second\n---\nSecond body.");
// Act
@@ -168,6 +173,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}");
}
[Fact]
public void DiscoverAndLoadSkills_NameMismatchesDirectory_ExcludesSkill()
{
// Arrange — directory name differs from the frontmatter name
_ = this.CreateSkillDirectoryWithRawContent(
"wrong-dir-name",
"---\nname: actual-skill-name\ndescription: A skill\n---\nBody.");
// Act
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
// Assert
Assert.Empty(skills);
}
[Fact]
public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResources()
{
@@ -454,6 +454,77 @@ public class ChatHistoryMemoryProviderTests
Times.Once);
}
[Fact]
public async Task InvokedAsync_CombinedFilterCanBeCompiled_WhenMultipleScopeFiltersProvidedAsync()
{
// Arrange
// This test reproduces a bug where combining multiple scope filters
// (e.g. userId + sessionId) produces an expression tree with dangling
// ParameterExpression references that fails at compile time.
ChatHistoryMemoryProviderOptions providerOptions = new()
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
MaxResults = 2,
ContextPrompt = "Here is the relevant chat history:\n"
};
ChatHistoryMemoryProviderScope searchScope = new()
{
ApplicationId = "app1",
AgentId = "agent1",
SessionId = "session1",
UserId = "user1"
};
System.Linq.Expressions.Expression<Func<Dictionary<string, object?>, bool>>? capturedFilter = null;
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Callback((string query, int maxResults, VectorSearchOptions<Dictionary<string, object?>> options, CancellationToken ct) =>
capturedFilter = options.Filter)
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
ChatHistoryMemoryProvider provider = new(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(searchScope, searchScope),
options: providerOptions);
ChatMessage requestMsg = new(ChatRole.User, "requesting relevant history");
AIContextProvider.InvokingContext invokingContext = new(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List<ChatMessage> { requestMsg } });
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert - The filter must be compilable and executable without expression tree scoping errors
Assert.NotNull(capturedFilter);
Func<Dictionary<string, object?>, bool> compiledFilter = capturedFilter!.Compile();
Dictionary<string, object?> matchingRecord = new()
{
["ApplicationId"] = "app1",
["AgentId"] = "agent1",
["SessionId"] = "session1",
["UserId"] = "user1"
};
Dictionary<string, object?> nonMatchingRecord = new()
{
["ApplicationId"] = "app1",
["AgentId"] = "agent1",
["SessionId"] = "other-session",
["UserId"] = "user1"
};
Assert.True(compiledFilter(matchingRecord));
Assert.False(compiledFilter(nonMatchingRecord));
}
[Theory]
[InlineData(false, false, 2)]
[InlineData(true, false, 2)]
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **agent-framework-redis**: Fix `RedisContextProvider` compatibility with redisvl 0.14.0 by using `AggregateHybridQuery` ([#3954](https://github.com/microsoft/agent-framework/pull/3954))
## [1.0.0rc3] - 2026-03-04
### Added
+1 -1
View File
@@ -44,7 +44,7 @@ packages = ["agent_framework_ag_ui", "agent_framework_ag_ui_examples"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests/ag_ui"]
pythonpath = ["."]
pythonpath = [".", "tests/ag_ui"]
markers = [
"integration: marks tests as integration tests that require external services",
]
@@ -4,6 +4,7 @@
import sys
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Mapping, MutableSequence, Sequence
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Generic, Literal, cast, overload
@@ -36,6 +37,13 @@ StreamFn = Callable[..., AsyncIterable[ChatResponseUpdate]]
ResponseFn = Callable[..., Awaitable[ChatResponse]]
def pytest_configure() -> None:
"""Ensure this test directory is on sys.path so helper modules can be imported by name."""
test_dir = str(Path(__file__).resolve().parent)
if test_dir not in sys.path:
sys.path.insert(0, test_dir)
class StreamingChatClientStub(
ChatMiddlewareLayer[OptionsCoT],
FunctionInvocationLayer[OptionsCoT],
@@ -241,3 +249,83 @@ def stream_from_updates_fixture() -> Callable[[list[ChatResponseUpdate]], Stream
def stub_agent() -> type[SupportsAgentRun]:
"""Return the StubAgent class for creating test instances."""
return StubAgent # type: ignore[return-value]
# ── Fixtures for golden / integration tests ──
@pytest.fixture
def collect_events() -> Callable[..., Any]:
"""Return an async helper that collects all events from an async generator."""
async def _collect(async_gen: AsyncIterable[Any]) -> list[Any]:
return [event async for event in async_gen]
return _collect
@pytest.fixture
def make_agent_wrapper() -> Callable[..., Any]:
"""Factory that builds an AgentFrameworkAgent from a stream function.
Usage::
agent = make_agent_wrapper(
stream_fn=stream_from_updates(updates),
state_schema=...,
)
events = [e async for e in agent.run(payload)]
"""
from agent_framework_ag_ui import AgentFrameworkAgent
def _factory(
stream_fn: StreamFn,
*,
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
require_confirmation: bool = True,
) -> Any:
client = StreamingChatClientStub(stream_fn)
stub = StubAgent(client=client)
return AgentFrameworkAgent(
agent=stub,
state_schema=state_schema,
predict_state_config=predict_state_config,
require_confirmation=require_confirmation,
)
return _factory
@pytest.fixture
def make_app() -> Callable[..., Any]:
"""Factory that builds a FastAPI app with an AG-UI endpoint.
Usage::
app = make_app(agent_or_wrapper, path="/test")
"""
from fastapi import FastAPI
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
def _factory(
agent: Any,
*,
path: str = "/",
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
default_state: dict[str, Any] | None = None,
) -> FastAPI:
app = FastAPI()
add_agent_framework_fastapi_endpoint(
app,
agent,
path=path,
state_schema=state_schema,
predict_state_config=predict_state_config,
default_state=default_state,
)
return app
return _factory
@@ -0,0 +1,175 @@
# Copyright (c) Microsoft. All rights reserved.
"""EventStream assertion helper for AG-UI regression tests."""
from __future__ import annotations
from typing import Any
class EventStream:
"""Wraps a list of AG-UI events with structured assertion methods.
Usage:
events = [event async for event in agent.run(payload)]
stream = EventStream(events)
stream.assert_bookends()
stream.assert_text_messages_balanced()
"""
def __init__(self, events: list[Any]) -> None:
self.events = events
def __len__(self) -> int:
return len(self.events)
def __iter__(self):
return iter(self.events)
def types(self) -> list[str]:
"""Return ordered list of event type strings."""
return [self._type_str(e) for e in self.events]
def get(self, event_type: str) -> list[Any]:
"""Filter events matching the given type string."""
return [e for e in self.events if self._type_str(e) == event_type]
def first(self, event_type: str) -> Any:
"""Return the first event matching the given type, or raise."""
matches = self.get(event_type)
if not matches:
raise ValueError(f"No event of type {event_type!r} found. Available: {self.types()}")
return matches[0]
def last(self, event_type: str) -> Any:
"""Return the last event matching the given type, or raise."""
matches = self.get(event_type)
if not matches:
raise ValueError(f"No event of type {event_type!r} found. Available: {self.types()}")
return matches[-1]
def snapshot(self) -> dict[str, Any]:
"""Return the latest StateSnapshotEvent snapshot dict."""
return self.last("STATE_SNAPSHOT").snapshot
def messages_snapshot(self) -> list[Any]:
"""Return the latest MessagesSnapshotEvent messages list."""
return self.last("MESSAGES_SNAPSHOT").messages
# ── Structural assertions ──
def assert_bookends(self) -> None:
"""Assert first event is RUN_STARTED and last is RUN_FINISHED."""
types = self.types()
assert types, "Event stream is empty"
assert types[0] == "RUN_STARTED", f"Expected RUN_STARTED first, got {types[0]}"
assert types[-1] == "RUN_FINISHED", f"Expected RUN_FINISHED last, got {types[-1]}"
def assert_has_run_lifecycle(self) -> None:
"""Assert RUN_STARTED is first and RUN_FINISHED exists (may not be last).
Use this instead of assert_bookends() for workflow resume streams where
_drain_open_message() can emit TEXT_MESSAGE_END after RUN_FINISHED.
"""
types = self.types()
assert types, "Event stream is empty"
assert types[0] == "RUN_STARTED", f"Expected RUN_STARTED first, got {types[0]}"
assert "RUN_FINISHED" in types, f"Expected RUN_FINISHED in stream. Types: {types}"
def assert_strict_types(self, expected: list[str]) -> None:
"""Assert exact type sequence match."""
actual = self.types()
assert actual == expected, f"Event type mismatch.\nExpected: {expected}\nActual: {actual}"
def assert_ordered_types(self, expected: list[str]) -> None:
"""Assert expected types appear as a subsequence (in order, not necessarily contiguous)."""
actual = self.types()
actual_idx = 0
for expected_type in expected:
found = False
while actual_idx < len(actual):
if actual[actual_idx] == expected_type:
actual_idx += 1
found = True
break
actual_idx += 1
if not found:
raise AssertionError(
f"Expected subsequence type {expected_type!r} not found after index {actual_idx}.\n"
f"Expected subsequence: {expected}\n"
f"Actual types: {actual}"
)
def assert_text_messages_balanced(self) -> None:
"""Assert every TEXT_MESSAGE_START has a matching TEXT_MESSAGE_END with the same message_id."""
starts: dict[str, int] = {}
ends: set[str] = set()
for i, event in enumerate(self.events):
t = self._type_str(event)
if t == "TEXT_MESSAGE_START":
mid = event.message_id
assert mid not in starts, f"Duplicate TEXT_MESSAGE_START for message_id={mid}"
starts[mid] = i
elif t == "TEXT_MESSAGE_END":
mid = event.message_id
assert mid in starts, f"TEXT_MESSAGE_END for unknown message_id={mid}"
assert mid not in ends, f"Duplicate TEXT_MESSAGE_END for message_id={mid}"
ends.add(mid)
unclosed = set(starts.keys()) - ends
assert not unclosed, f"Unclosed text messages: {unclosed}"
def assert_tool_calls_balanced(self) -> None:
"""Assert every TOOL_CALL_START has a matching TOOL_CALL_END with the same tool_call_id."""
starts: dict[str, int] = {}
ends: set[str] = set()
for i, event in enumerate(self.events):
t = self._type_str(event)
if t == "TOOL_CALL_START":
tid = event.tool_call_id
assert tid not in starts, f"Duplicate TOOL_CALL_START for tool_call_id={tid}"
starts[tid] = i
elif t == "TOOL_CALL_END":
tid = event.tool_call_id
assert tid in starts, f"TOOL_CALL_END for unknown tool_call_id={tid}"
assert tid not in ends, f"Duplicate TOOL_CALL_END for tool_call_id={tid}"
ends.add(tid)
unclosed = set(starts.keys()) - ends
assert not unclosed, f"Unclosed tool calls: {unclosed}"
def assert_no_run_error(self) -> None:
"""Assert no RUN_ERROR events exist."""
errors = self.get("RUN_ERROR")
if errors:
messages = [getattr(e, "message", str(e)) for e in errors]
raise AssertionError(f"Found {len(errors)} RUN_ERROR event(s): {messages}")
def assert_has_type(self, event_type: str) -> None:
"""Assert at least one event of the given type exists."""
assert event_type in self.types(), f"Expected {event_type!r} in stream. Available: {self.types()}"
def assert_message_ids_consistent(self) -> None:
"""Assert TEXT_MESSAGE_CONTENT events reference valid, open message_ids."""
open_messages: set[str] = set()
for event in self.events:
t = self._type_str(event)
if t == "TEXT_MESSAGE_START":
open_messages.add(event.message_id)
elif t == "TEXT_MESSAGE_END":
open_messages.discard(event.message_id)
elif t == "TEXT_MESSAGE_CONTENT":
mid = event.message_id
assert mid in open_messages, f"TEXT_MESSAGE_CONTENT references message_id={mid} which is not open"
# ── Internal ──
@staticmethod
def _type_str(event: Any) -> str:
"""Extract event type as a plain string."""
t = getattr(event, "type", None)
if t is None:
return type(event).__name__
if isinstance(t, str):
return t
return getattr(t, "value", str(t))
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
"""Conftest for golden tests — ensures parent test dir is importable."""
import sys
from pathlib import Path
def pytest_configure() -> None:
"""Ensure parent test directory is on sys.path for helper module imports."""
parent_test_dir = str(Path(__file__).resolve().parent.parent)
if parent_test_dir not in sys.path:
sys.path.insert(0, parent_test_dir)
@@ -0,0 +1,140 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the basic agentic chat scenario."""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent
from event_stream import EventStream
from agent_framework_ag_ui import AgentFrameworkAgent
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(updates=updates)
return AgentFrameworkAgent(agent=stub, **kwargs)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
BASIC_PAYLOAD: dict[str, Any] = {
"thread_id": "thread-chat",
"run_id": "run-chat",
"messages": [{"role": "user", "content": "Hello"}],
}
def _text_update(text: str) -> AgentResponseUpdate:
return AgentResponseUpdate(contents=[Content.from_text(text=text)], role="assistant")
def _snapshot_role(msg: Any) -> str:
"""Extract role string from a snapshot message (Pydantic model or dict)."""
role = getattr(msg, "role", None) or (msg.get("role") if isinstance(msg, dict) else None)
if role is None:
return ""
return str(getattr(role, "value", role))
def _snapshot_content(msg: Any) -> str:
"""Extract content string from a snapshot message."""
content = getattr(msg, "content", None) or (msg.get("content") if isinstance(msg, dict) else "")
return str(content) if content else ""
# ── Golden stream tests ──
async def test_basic_chat_golden_event_sequence() -> None:
"""Assert the exact event type sequence for a single text response."""
agent = _build_agent([_text_update("Hi there!")])
stream = await _run(agent, BASIC_PAYLOAD)
stream.assert_strict_types(
[
"RUN_STARTED",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_END",
"MESSAGES_SNAPSHOT",
"RUN_FINISHED",
]
)
async def test_basic_chat_bookends() -> None:
"""RUN_STARTED is first, RUN_FINISHED is last."""
agent = _build_agent([_text_update("reply")])
stream = await _run(agent, BASIC_PAYLOAD)
stream.assert_bookends()
async def test_basic_chat_text_messages_balanced() -> None:
"""Every TEXT_MESSAGE_START has a matching TEXT_MESSAGE_END."""
agent = _build_agent([_text_update("reply")])
stream = await _run(agent, BASIC_PAYLOAD)
stream.assert_text_messages_balanced()
async def test_basic_chat_no_errors() -> None:
"""No RUN_ERROR events in a normal flow."""
agent = _build_agent([_text_update("reply")])
stream = await _run(agent, BASIC_PAYLOAD)
stream.assert_no_run_error()
async def test_basic_chat_message_id_consistency() -> None:
"""All text events reference the same message_id."""
agent = _build_agent([_text_update("reply")])
stream = await _run(agent, BASIC_PAYLOAD)
start = stream.first("TEXT_MESSAGE_START")
content = stream.first("TEXT_MESSAGE_CONTENT")
end = stream.first("TEXT_MESSAGE_END")
assert start.message_id == content.message_id == end.message_id
async def test_multi_chunk_text_golden_sequence() -> None:
"""Streaming multiple chunks produces START + multiple CONTENT + END."""
agent = _build_agent([_text_update("Hello "), _text_update("world!")])
stream = await _run(agent, BASIC_PAYLOAD)
stream.assert_strict_types(
[
"RUN_STARTED",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_END",
"MESSAGES_SNAPSHOT",
"RUN_FINISHED",
]
)
stream.assert_text_messages_balanced()
stream.assert_message_ids_consistent()
async def test_messages_snapshot_contains_assistant_reply() -> None:
"""MessagesSnapshotEvent includes the assistant's accumulated text."""
agent = _build_agent([_text_update("Hello there")])
stream = await _run(agent, BASIC_PAYLOAD)
snapshot = stream.messages_snapshot()
assistant_msgs = [m for m in snapshot if _snapshot_role(m) == "assistant"]
assert assistant_msgs, "No assistant message in snapshot"
assert any("Hello there" in _snapshot_content(m) for m in assistant_msgs)
async def test_empty_messages_produces_start_and_finish() -> None:
"""Empty message list still produces RUN_STARTED and RUN_FINISHED."""
agent = _build_agent([_text_update("reply")])
payload = {"thread_id": "t1", "run_id": "r1", "messages": []}
stream = await _run(agent, payload)
stream.assert_bookends()
assert "TEXT_MESSAGE_START" not in stream.types()
@@ -0,0 +1,236 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the backend (server-side) tools scenario."""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent
from event_stream import EventStream
from agent_framework_ag_ui import AgentFrameworkAgent
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(updates=updates)
return AgentFrameworkAgent(agent=stub, **kwargs)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-tools",
"run_id": "run-tools",
"messages": [{"role": "user", "content": "What's the weather?"}],
}
# ── Golden stream tests ──
async def test_tool_call_lifecycle_golden_sequence() -> None:
"""Assert the full event sequence for a tool call → result → text response."""
updates = [
# LLM calls the tool
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
role="assistant",
),
# Tool result comes back
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F and sunny")],
role="assistant",
),
# LLM responds with text
AgentResponseUpdate(
contents=[Content.from_text(text="It's 72°F and sunny in SF!")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_ordered_types(
[
"RUN_STARTED",
"TEXT_MESSAGE_START", # Synthetic start for tool-only message
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"TOOL_CALL_END",
"TOOL_CALL_RESULT",
"TEXT_MESSAGE_END", # End of synthetic message
"TEXT_MESSAGE_START", # New message for text response
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_END",
"MESSAGES_SNAPSHOT",
"RUN_FINISHED",
]
)
async def test_tool_calls_balanced() -> None:
"""Every TOOL_CALL_START has a matching TOOL_CALL_END."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's 72°F!")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_tool_calls_balanced()
async def test_text_messages_balanced_with_tools() -> None:
"""Text messages are properly balanced even around tool calls."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's 72°F!")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_text_messages_balanced()
async def test_tool_call_id_matches_result() -> None:
"""TOOL_CALL_START and TOOL_CALL_RESULT reference the same tool_call_id."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments="{}")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
start = stream.first("TOOL_CALL_START")
result = stream.first("TOOL_CALL_RESULT")
assert start.tool_call_id == result.tool_call_id == "call-1"
async def test_tool_result_content_preserved() -> None:
"""TOOL_CALL_RESULT event carries the tool's result content."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments="{}")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F and sunny")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
result = stream.first("TOOL_CALL_RESULT")
assert result.content == "72°F and sunny"
async def test_no_run_error_on_tool_flow() -> None:
"""Tool call flow doesn't produce RUN_ERROR."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments="{}")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_no_run_error()
stream.assert_bookends()
async def test_multiple_sequential_tool_calls() -> None:
"""Multiple sequential tool calls each produce balanced START/END pairs."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="tool_a", call_id="call-a", arguments="{}")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-a", result="result-a")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call(name="tool_b", call_id="call-b", arguments="{}")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-b", result="result-b")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="Done!")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_tool_calls_balanced()
stream.assert_text_messages_balanced()
stream.assert_bookends()
# Both tool calls should appear
starts = stream.get("TOOL_CALL_START")
assert len(starts) == 2
assert {s.tool_call_name for s in starts} == {"tool_a", "tool_b"}
async def test_messages_snapshot_includes_tool_calls() -> None:
"""MessagesSnapshotEvent includes tool call and result messages."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city":"SF"}')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's warm!")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_has_type("MESSAGES_SNAPSHOT")
snapshot = stream.messages_snapshot()
# Should have: user message, assistant with tool_calls, tool result, assistant text
assert len(snapshot) >= 3
@@ -0,0 +1,91 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the generative UI (workflow-as-agent) scenario."""
from __future__ import annotations
from typing import Any
from agent_framework import WorkflowBuilder, WorkflowContext, executor
from event_stream import EventStream
from typing_extensions import Never
from agent_framework_ag_ui import AgentFrameworkWorkflow
async def _run(wrapper: AgentFrameworkWorkflow, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in wrapper.run(payload)])
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-gen-ui-agent",
"run_id": "run-gen-ui-agent",
"messages": [{"role": "user", "content": "Generate a UI"}],
}
# ── Golden stream tests ──
async def test_workflow_agent_golden_sequence() -> None:
"""Workflow-as-agent: emits step events and text content."""
@executor(id="generator")
async def generator(message: Any, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("Here is your generated UI content!")
workflow = WorkflowBuilder(start_executor=generator).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_text_messages_balanced()
# Should have step events for the executor
stream.assert_has_type("STEP_STARTED")
stream.assert_has_type("STEP_FINISHED")
# Should have text message content
stream.assert_has_type("TEXT_MESSAGE_CONTENT")
async def test_workflow_agent_step_names_match() -> None:
"""Step started/finished events reference the executor name."""
@executor(id="my_executor")
async def my_executor(message: Any, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("Done!")
workflow = WorkflowBuilder(start_executor=my_executor).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, PAYLOAD)
started = [e for e in stream.get("STEP_STARTED") if getattr(e, "step_name", "") == "my_executor"]
finished = [e for e in stream.get("STEP_FINISHED") if getattr(e, "step_name", "") == "my_executor"]
assert started, "Expected STEP_STARTED for 'my_executor'"
assert finished, "Expected STEP_FINISHED for 'my_executor'"
async def test_workflow_agent_ordered_events() -> None:
"""Workflow events follow expected ordering: RUN_STARTED → STEP_STARTED → content → STEP_FINISHED → RUN_FINISHED."""
@executor(id="my_step")
async def my_step(message: Any, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("Generated content")
workflow = WorkflowBuilder(start_executor=my_step).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, PAYLOAD)
stream.assert_ordered_types(
[
"RUN_STARTED",
"STEP_STARTED",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"STEP_FINISHED",
"TEXT_MESSAGE_END",
"RUN_FINISHED",
]
)
@@ -0,0 +1,135 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the client-side (declaration-only) tools scenario."""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent
from event_stream import EventStream
from agent_framework_ag_ui import AgentFrameworkAgent
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(updates=updates)
return AgentFrameworkAgent(agent=stub, **kwargs)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-gen-ui-tool",
"run_id": "run-gen-ui-tool",
"messages": [{"role": "user", "content": "Show me a chart"}],
"tools": [
{
"type": "function",
"function": {
"name": "render_chart",
"description": "Render a chart in the UI",
"parameters": {
"type": "object",
"properties": {"data": {"type": "array"}},
},
},
}
],
}
# ── Golden stream tests ──
async def test_declaration_only_tool_golden_sequence() -> None:
"""Declaration-only tool: TOOL_CALL_START/ARGS emitted, TOOL_CALL_END at stream end."""
# The LLM calls a client-side tool (no server-side execution)
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="render_chart",
call_id="call-chart",
arguments='{"data": [1, 2, 3]}',
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
# Tool call start and args should be present
stream.assert_has_type("TOOL_CALL_START")
stream.assert_has_type("TOOL_CALL_ARGS")
# TOOL_CALL_END should be emitted (via get_pending_without_end)
stream.assert_has_type("TOOL_CALL_END")
stream.assert_tool_calls_balanced()
async def test_declaration_only_tool_no_tool_call_result() -> None:
"""Declaration-only tools should NOT produce TOOL_CALL_RESULT events."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="render_chart",
call_id="call-chart",
arguments='{"data": [1, 2, 3]}',
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
assert "TOOL_CALL_RESULT" not in stream.types(), "Declaration-only tools should not have TOOL_CALL_RESULT"
async def test_declaration_only_tool_text_messages_balanced() -> None:
"""Text messages remain balanced even with declaration-only tools."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="render_chart",
call_id="call-chart",
arguments='{"data": [1, 2, 3]}',
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_text_messages_balanced()
async def test_declaration_only_tool_messages_snapshot() -> None:
"""MessagesSnapshotEvent includes the tool call for declaration-only tools."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="render_chart",
call_id="call-chart",
arguments='{"data": [1, 2, 3]}',
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_has_type("MESSAGES_SNAPSHOT")
@@ -0,0 +1,196 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the HITL (human-in-the-loop) approval scenario."""
from __future__ import annotations
import json
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent
from event_stream import EventStream
from agent_framework_ag_ui import AgentFrameworkAgent
PREDICT_CONFIG = {
"tasks": {
"tool": "generate_task_steps",
"tool_argument": "steps",
}
}
STATE_SCHEMA = {
"tasks": {"type": "array", "items": {"type": "object"}},
}
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(updates=updates)
return AgentFrameworkAgent(
agent=stub,
state_schema=STATE_SCHEMA,
predict_state_config=PREDICT_CONFIG,
require_confirmation=True,
**kwargs,
)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
STEPS = [
{"description": "Step 1: Plan", "status": "enabled"},
{"description": "Step 2: Execute", "status": "enabled"},
]
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-hitl",
"run_id": "run-hitl",
"messages": [{"role": "user", "content": "Plan my tasks"}],
"state": {"tasks": []},
}
# ── Turn 1: Tool call → confirm_changes → interrupt ──
async def test_hitl_turn1_golden_sequence() -> None:
"""Turn 1 emits tool call, confirm_changes, and finishes with interrupt."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="generate_task_steps",
call_id="call-steps",
arguments=json.dumps({"steps": STEPS}),
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
# Should have: tool call start/args/end for the primary tool,
# then TOOL_CALL_END, STATE_SNAPSHOT, confirm_changes cycle
stream.assert_bookends()
stream.assert_no_run_error()
# confirm_changes tool call should be present
tool_starts = stream.get("TOOL_CALL_START")
tool_names = [getattr(s, "tool_call_name", None) for s in tool_starts]
assert "generate_task_steps" in tool_names
assert "confirm_changes" in tool_names
# RUN_FINISHED should have interrupt metadata
finished = stream.last("RUN_FINISHED")
interrupt = getattr(finished, "interrupt", None)
assert interrupt is not None, "Expected interrupt in RUN_FINISHED"
assert len(interrupt) > 0
async def test_hitl_turn1_tool_calls_balanced() -> None:
"""All tool calls in turn 1 (primary + confirm_changes) are balanced."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="generate_task_steps",
call_id="call-steps",
arguments=json.dumps({"steps": STEPS}),
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_tool_calls_balanced()
async def test_hitl_turn1_text_messages_balanced() -> None:
"""Text messages are balanced even in the approval flow."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="generate_task_steps",
call_id="call-steps",
arguments=json.dumps({"steps": STEPS}),
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_text_messages_balanced()
# ── Turn 2: Resume with approval → confirmation message → no interrupt ──
async def test_hitl_turn2_resume_with_approval() -> None:
"""Resuming with confirm_changes result emits confirmation text and finishes cleanly."""
# Turn 2: user sends confirm_changes result as resume
# The agent wrapper sees a confirm_changes response and emits a confirmation message
confirm_result = json.dumps(
{
"accepted": True,
"steps": STEPS,
}
)
# Build payload with resume containing the approval
# For confirm_changes, the messages should include the tool result
payload: dict[str, Any] = {
"thread_id": "thread-hitl",
"run_id": "run-hitl-2",
"messages": [
{"role": "user", "content": "Plan my tasks"},
{
"role": "assistant",
"tool_calls": [
{
"id": "confirm-id-1",
"type": "function",
"function": {"name": "confirm_changes", "arguments": json.dumps({"steps": STEPS})},
}
],
},
{
"role": "tool",
"toolCallId": "confirm-id-1",
"content": confirm_result,
},
],
"state": {"tasks": []},
}
# In turn 2, the agent sees the confirm_changes result and emits a confirmation text
updates = [
AgentResponseUpdate(
contents=[Content.from_text(text="Tasks confirmed!")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, payload)
stream.assert_bookends()
stream.assert_text_messages_balanced()
stream.assert_no_run_error()
# Should have text message content (the confirmation message)
text_events = stream.get("TEXT_MESSAGE_CONTENT")
assert text_events, "Expected confirmation text message"
# RUN_FINISHED should NOT have interrupt (approval completed)
finished = stream.last("RUN_FINISHED")
interrupt = getattr(finished, "interrupt", None)
assert not interrupt, f"Expected no interrupt after approval, got {interrupt}"
@@ -0,0 +1,130 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the predictive state scenario."""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent
from event_stream import EventStream
from agent_framework_ag_ui import AgentFrameworkAgent
PREDICT_CONFIG = {
"document": {
"tool": "update_document",
"tool_argument": "content",
}
}
STATE_SCHEMA = {
"document": {"type": "string"},
}
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(updates=updates)
return AgentFrameworkAgent(
agent=stub,
state_schema=STATE_SCHEMA,
predict_state_config=PREDICT_CONFIG,
require_confirmation=False,
**kwargs,
)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-predict",
"run_id": "run-predict",
"messages": [{"role": "user", "content": "Write a document"}],
"state": {"document": ""},
}
# ── Golden stream tests ──
async def test_predictive_state_emits_deltas_during_tool_args() -> None:
"""STATE_DELTA events are emitted as tool arguments stream in."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="update_document", call_id="call-1", arguments="")],
role="assistant",
),
AgentResponseUpdate(
contents=[
Content.from_function_call(name="update_document", call_id="call-1", arguments='{"content": "Hello')
],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call(name="update_document", call_id="call-1", arguments=' world"}')],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
# PredictState custom event should be present
custom_events = stream.get("CUSTOM")
predict_events = [e for e in custom_events if getattr(e, "name", None) == "PredictState"]
assert predict_events, "Expected PredictState custom event"
# STATE_DELTA events should be emitted during tool arg streaming
assert "STATE_DELTA" in stream.types(), "Expected STATE_DELTA events during predictive streaming"
async def test_predictive_state_snapshot_after_tool_end() -> None:
"""STATE_SNAPSHOT is emitted when a predictive tool completes (no confirmation)."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="update_document", call_id="call-1", arguments='{"content": "Final text"}'
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
# Should have initial state snapshot + updated snapshot after tool completion
snapshots = stream.get("STATE_SNAPSHOT")
assert len(snapshots) >= 1, "Expected at least one STATE_SNAPSHOT"
async def test_predictive_state_ordered_events() -> None:
"""Event ordering: RUN_STARTED → PredictState → STATE_SNAPSHOT → TOOL_CALL_* → STATE_SNAPSHOT → RUN_FINISHED."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(name="update_document", call_id="call-1", arguments='{"content": "doc"}')
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_ordered_types(
[
"RUN_STARTED",
"CUSTOM", # PredictState
"STATE_SNAPSHOT", # Initial state
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"RUN_FINISHED",
]
)
@@ -0,0 +1,110 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the shared state (structured output) scenario."""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent
from event_stream import EventStream
from pydantic import BaseModel
from agent_framework_ag_ui import AgentFrameworkAgent
class RecipeState(BaseModel):
recipe_title: str = ""
ingredients: list[str] = []
message: str = ""
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(
updates=updates,
default_options={"tools": None, "response_format": RecipeState},
)
return AgentFrameworkAgent(
agent=stub,
state_schema={
"recipe_title": {"type": "string"},
"ingredients": {"type": "array", "items": {"type": "string"}},
},
**kwargs,
)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-state",
"run_id": "run-state",
"messages": [{"role": "user", "content": "Give me a pasta recipe"}],
"state": {"recipe_title": "", "ingredients": []},
}
# ── Golden stream tests ──
async def test_shared_state_emits_state_snapshot() -> None:
"""Structured output agent emits STATE_SNAPSHOT with parsed model fields."""
# The structured output agent gets a response that the framework parses as RecipeState
updates = [
AgentResponseUpdate(
contents=[
Content.from_text(
text='{"recipe_title": "Pasta Carbonara", "ingredients": ["pasta", "eggs", "cheese"], "message": "Here is your recipe!"}'
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
# Should have STATE_SNAPSHOT with the initial state at minimum
stream.assert_has_type("STATE_SNAPSHOT")
async def test_shared_state_initial_snapshot_on_first_update() -> None:
"""When state_schema and state are provided, initial STATE_SNAPSHOT is emitted after RUN_STARTED."""
updates = [
AgentResponseUpdate(
contents=[Content.from_text(text='{"recipe_title": "Test", "ingredients": [], "message": "hi"}')],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
# RUN_STARTED should be followed by STATE_SNAPSHOT (initial state)
stream.assert_ordered_types(["RUN_STARTED", "STATE_SNAPSHOT"])
async def test_shared_state_text_emitted_from_message_field() -> None:
"""Structured output's 'message' field is emitted as text message events."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_text(
text='{"recipe_title": "Pasta", "ingredients": ["pasta"], "message": "Enjoy your pasta!"}'
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
# Text should be emitted from the message field
text_contents = stream.get("TEXT_MESSAGE_CONTENT")
if text_contents:
combined = "".join(getattr(e, "delta", "") for e in text_contents)
assert "Enjoy your pasta!" in combined
@@ -0,0 +1,211 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the workflow HITL (subgraphs) scenario.
Extends the existing test_subgraphs_example_agent.py with EventStream assertions
on full event ordering, balancing, and interrupt structure.
"""
from __future__ import annotations
import json
from typing import Any
from event_stream import EventStream
from agent_framework_ag_ui_examples.agents.subgraphs_agent import subgraphs_agent
async def _run(agent: Any, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
# ── Turn 1: Initial request → flight interrupt ──
async def test_subgraphs_turn1_golden_bookends() -> None:
"""Turn 1 starts with RUN_STARTED and ends with RUN_FINISHED."""
agent = subgraphs_agent()
stream = await _run(
agent,
{
"thread_id": "thread-sub-golden-1",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip to San Francisco"}],
},
)
stream.assert_bookends()
async def test_subgraphs_turn1_no_errors() -> None:
"""Turn 1 completes without errors."""
agent = subgraphs_agent()
stream = await _run(
agent,
{
"thread_id": "thread-sub-golden-2",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip"}],
},
)
stream.assert_no_run_error()
async def test_subgraphs_turn1_has_step_events() -> None:
"""Turn 1 emits STEP_STARTED and STEP_FINISHED for workflow executors."""
agent = subgraphs_agent()
stream = await _run(
agent,
{
"thread_id": "thread-sub-golden-3",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip"}],
},
)
stream.assert_has_type("STEP_STARTED")
stream.assert_has_type("STEP_FINISHED")
async def test_subgraphs_turn1_interrupt_structure() -> None:
"""Turn 1 RUN_FINISHED carries flight interrupt with correct structure."""
agent = subgraphs_agent()
stream = await _run(
agent,
{
"thread_id": "thread-sub-golden-4",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip to SF"}],
},
)
finished = stream.last("RUN_FINISHED")
interrupt = getattr(finished, "interrupt", None)
assert interrupt is not None, "Expected interrupt in RUN_FINISHED"
assert isinstance(interrupt, list)
assert len(interrupt) > 0
assert interrupt[0]["value"]["agent"] == "flights"
assert len(interrupt[0]["value"]["options"]) == 2
async def test_subgraphs_turn1_text_messages_balanced() -> None:
"""All text messages in turn 1 are properly balanced."""
agent = subgraphs_agent()
stream = await _run(
agent,
{
"thread_id": "thread-sub-golden-5",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip"}],
},
)
stream.assert_text_messages_balanced()
async def test_subgraphs_turn1_ordered_flow() -> None:
"""Turn 1 event ordering: RUN_STARTED → STATE_SNAPSHOT → STEP_* → TOOL_CALL_* → RUN_FINISHED."""
agent = subgraphs_agent()
stream = await _run(
agent,
{
"thread_id": "thread-sub-golden-6",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip"}],
},
)
stream.assert_ordered_types(
[
"RUN_STARTED",
"STATE_SNAPSHOT",
"STEP_STARTED",
"RUN_FINISHED",
]
)
# ── Multi-turn: Flight selection → hotel interrupt → completion ──
async def test_subgraphs_full_flow_event_ordering() -> None:
"""Complete 3-turn flow maintains proper event ordering throughout."""
agent = subgraphs_agent()
thread_id = "thread-sub-golden-full"
# Turn 1
stream1 = await _run(
agent,
{
"thread_id": thread_id,
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip to SF from Amsterdam"}],
},
)
stream1.assert_bookends()
stream1.assert_no_run_error()
# Extract flight interrupt
finished1 = stream1.last("RUN_FINISHED")
interrupt1 = finished1.model_dump()["interrupt"][0]
# Turn 2: Select flight
stream2 = await _run(
agent,
{
"thread_id": thread_id,
"run_id": "run-2",
"resume": {
"interrupts": [
{
"id": interrupt1["id"],
"value": json.dumps(
{
"airline": "United",
"departure": "Amsterdam (AMS)",
"arrival": "San Francisco (SFO)",
"price": "$720",
"duration": "12h 15m",
}
),
}
]
},
},
)
stream2.assert_bookends()
stream2.assert_no_run_error()
# Should now have hotel interrupt
finished2 = stream2.last("RUN_FINISHED")
interrupt2 = finished2.model_dump()["interrupt"]
assert interrupt2[0]["value"]["agent"] == "hotels"
# Turn 3: Select hotel
stream3 = await _run(
agent,
{
"thread_id": thread_id,
"run_id": "run-3",
"resume": {
"interrupts": [
{
"id": interrupt2[0]["id"],
"value": json.dumps(
{
"name": "The Ritz-Carlton",
"location": "Nob Hill",
"price_per_night": "$550/night",
"rating": "4.8 stars",
}
),
}
]
},
},
)
stream3.assert_bookends()
stream3.assert_no_run_error()
stream3.assert_text_messages_balanced()
# Final turn should not have interrupt
finished3 = stream3.last("RUN_FINISHED")
final_interrupt = getattr(finished3, "interrupt", None)
assert not final_interrupt, f"Expected no interrupt after completion, got {final_interrupt}"
@@ -0,0 +1,962 @@
# Copyright (c) Microsoft. All rights reserved.
"""Comprehensive golden event-stream tests for AgentFrameworkWorkflow.
Covers the full matrix of workflow-specific AG-UI patterns:
- request_info → TOOL_CALL lifecycle and balancing
- Executor step events and activity snapshots
- Text output, dict output, BaseEvent passthrough, AgentResponse output
- Text deduplication across workflow outputs
- Workflow error handling → RUN_ERROR
- Multi-turn interrupt/resume round-trips
- Empty turns with pending requests
- Custom workflow events
- Text message draining on request_info and executor boundaries
"""
import json
from typing import Any, cast
from ag_ui.core import EventType, StateSnapshotEvent
from agent_framework import (
AgentResponse,
Content,
Executor,
Message,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
executor,
handler,
response_handler,
)
from event_stream import EventStream
from typing_extensions import Never
from agent_framework_ag_ui import AgentFrameworkWorkflow
async def _run(wrapper: AgentFrameworkWorkflow, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in wrapper.run(payload)])
def _payload(
msg: str = "go",
*,
thread_id: str = "thread-wf",
run_id: str = "run-wf",
**extra: Any,
) -> dict[str, Any]:
return {"thread_id": thread_id, "run_id": run_id, "messages": [{"role": "user", "content": msg}], **extra}
# ──────────────────────────────────────────────────────────────────────
# 1. Basic workflow text output
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_text_output_golden_sequence() -> None:
"""Simple text output: RUN_STARTED → STEP_STARTED → TEXT_* → STEP_FINISHED → TEXT_MESSAGE_END → RUN_FINISHED."""
@executor(id="greeter")
async def greeter(message: Any, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("Hello from workflow!")
workflow = WorkflowBuilder(start_executor=greeter).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_text_messages_balanced()
stream.assert_has_type("TEXT_MESSAGE_START")
stream.assert_has_type("TEXT_MESSAGE_CONTENT")
stream.assert_has_type("TEXT_MESSAGE_END")
# Verify actual content
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
assert "Hello from workflow!" in deltas
async def test_workflow_text_output_message_id_consistency() -> None:
"""All text events for a single output share the same message_id."""
@executor(id="echo")
async def echo(message: Any, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("echo reply")
workflow = WorkflowBuilder(start_executor=echo).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_message_ids_consistent()
# ──────────────────────────────────────────────────────────────────────
# 2. Executor step events and activity snapshots
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_executor_lifecycle_events() -> None:
"""Executor invocation produces STEP_STARTED, ACTIVITY_SNAPSHOT, STEP_FINISHED."""
@executor(id="worker")
async def worker(message: Any, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("done")
workflow = WorkflowBuilder(start_executor=worker).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
# Step events with executor ID
started = [e for e in stream.get("STEP_STARTED") if getattr(e, "step_name", "") == "worker"]
finished = [e for e in stream.get("STEP_FINISHED") if getattr(e, "step_name", "") == "worker"]
assert started, "Expected STEP_STARTED for 'worker'"
assert finished, "Expected STEP_FINISHED for 'worker'"
# Activity snapshots
activities = stream.get("ACTIVITY_SNAPSHOT")
assert activities, "Expected ACTIVITY_SNAPSHOT events"
# Check one of them has executor payload
executor_activities = [a for a in activities if getattr(a, "activity_type", None) == "executor"]
assert executor_activities, "Expected executor-type activity snapshots"
async def test_workflow_executor_step_ordering() -> None:
"""STEP_STARTED comes before content, STEP_FINISHED comes after."""
@executor(id="orderer")
async def orderer(message: Any, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("ordered output")
workflow = WorkflowBuilder(start_executor=orderer).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_ordered_types(
[
"RUN_STARTED",
"STEP_STARTED",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"STEP_FINISHED",
"RUN_FINISHED",
]
)
# ──────────────────────────────────────────────────────────────────────
# 3. Dict output → CUSTOM workflow_output
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_dict_output_maps_to_custom_event() -> None:
"""Non-chat dict output is emitted as CUSTOM workflow_output event."""
@executor(id="structured")
async def structured(message: Any, ctx: WorkflowContext[Never, dict[str, int]]) -> None:
await ctx.yield_output({"count": 42, "status": 1})
workflow = WorkflowBuilder(start_executor=structured).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
stream.assert_no_run_error()
customs = [e for e in stream.get("CUSTOM") if getattr(e, "name", None) == "workflow_output"]
assert len(customs) == 1
assert customs[0].value == {"count": 42, "status": 1}
# Should NOT have TEXT_MESSAGE events for dict output
assert "TEXT_MESSAGE_CONTENT" not in stream.types()
# ──────────────────────────────────────────────────────────────────────
# 4. BaseEvent passthrough
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_base_event_passthrough() -> None:
"""AG-UI BaseEvent outputs are yielded directly, not wrapped."""
@executor(id="stateful")
async def stateful(message: Any, ctx: WorkflowContext[Never, StateSnapshotEvent]) -> None:
await ctx.yield_output(StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot={"active_agent": "flights"}))
workflow = WorkflowBuilder(start_executor=stateful).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
snapshots = stream.get("STATE_SNAPSHOT")
assert len(snapshots) == 1
assert snapshots[0].snapshot["active_agent"] == "flights"
# ──────────────────────────────────────────────────────────────────────
# 5. AgentResponse output (conversation payload)
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_agent_response_output_extracts_latest_assistant() -> None:
"""AgentResponse output uses only the latest assistant message, not full history."""
@executor(id="responder")
async def responder(message: Any, ctx: WorkflowContext[Never, AgentResponse]) -> None:
response = AgentResponse(
messages=[
Message(role="user", contents=[Content.from_text("My order is damaged")]),
Message(role="assistant", contents=[Content.from_text("I'll process your replacement.")]),
]
)
await ctx.yield_output(response)
workflow = WorkflowBuilder(start_executor=responder).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
stream.assert_text_messages_balanced()
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
assert deltas == ["I'll process your replacement."]
# ──────────────────────────────────────────────────────────────────────
# 6. Custom workflow events
# ──────────────────────────────────────────────────────────────────────
class ProgressEvent(WorkflowEvent):
"""Custom workflow event for testing CUSTOM event mapping."""
def __init__(self, progress: int) -> None:
super().__init__("custom_progress", data={"progress": progress})
async def test_workflow_custom_events() -> None:
"""Custom workflow events are mapped to CUSTOM AG-UI events."""
@executor(id="progress_tracker")
async def progress_tracker(message: Any, ctx: WorkflowContext[Never, str]) -> None:
await ctx.add_event(ProgressEvent(25))
await ctx.yield_output("In progress...")
await ctx.add_event(ProgressEvent(100))
workflow = WorkflowBuilder(start_executor=progress_tracker).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
stream.assert_no_run_error()
progress_events = [e for e in stream.get("CUSTOM") if getattr(e, "name", None) == "custom_progress"]
assert len(progress_events) == 2
assert progress_events[0].value == {"progress": 25}
assert progress_events[1].value == {"progress": 100}
# ──────────────────────────────────────────────────────────────────────
# 7. request_info → TOOL_CALL lifecycle
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_request_info_tool_call_lifecycle() -> None:
"""request_info emits TOOL_CALL_START/ARGS/END cycle plus CUSTOM request_info."""
@executor(id="requester")
async def requester(message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info("Need approval", str, request_id="req-1")
workflow = WorkflowBuilder(start_executor=requester).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
stream.assert_no_run_error()
# Tool call lifecycle
stream.assert_ordered_types(
[
"RUN_STARTED",
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"TOOL_CALL_END",
"CUSTOM", # request_info
"RUN_FINISHED",
]
)
# Verify tool call details
start = stream.first("TOOL_CALL_START")
assert start.tool_call_id == "req-1"
assert start.tool_call_name == "request_info"
# TOOL_CALL_ARGS should contain the request payload
args = stream.first("TOOL_CALL_ARGS")
assert args.tool_call_id == "req-1"
parsed_args = json.loads(args.delta)
assert parsed_args["request_id"] == "req-1"
# Tool calls should be balanced
stream.assert_tool_calls_balanced()
async def test_workflow_request_info_interrupt_in_run_finished() -> None:
"""request_info populates RUN_FINISHED.interrupt with the request metadata."""
@executor(id="requester")
async def requester(message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info(
{"message": "Choose a flight", "options": [{"airline": "KLM"}], "agent": "flights"},
dict,
request_id="flights-choice",
)
workflow = WorkflowBuilder(start_executor=requester).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
finished = stream.last("RUN_FINISHED")
interrupt = finished.model_dump().get("interrupt")
assert isinstance(interrupt, list)
assert len(interrupt) == 1
assert interrupt[0]["id"] == "flights-choice"
assert interrupt[0]["value"]["agent"] == "flights"
async def test_workflow_request_info_emits_interrupt_card_event() -> None:
"""request_info with dict data emits a WorkflowInterruptEvent custom event."""
@executor(id="requester")
async def requester(message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info(
{"message": "Pick one", "options": ["A", "B"]},
dict,
request_id="pick-1",
)
workflow = WorkflowBuilder(start_executor=requester).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
interrupt_cards = [e for e in stream.get("CUSTOM") if getattr(e, "name", None) == "WorkflowInterruptEvent"]
assert interrupt_cards, "Expected WorkflowInterruptEvent custom event"
# ──────────────────────────────────────────────────────────────────────
# 8. Text message draining on request_info boundary
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_text_drained_before_request_info() -> None:
"""Open text message is closed (TEXT_MESSAGE_END) before request_info tool calls begin."""
@executor(id="text_then_request")
async def text_then_request(message: Any, ctx: WorkflowContext) -> None:
await ctx.yield_output("Please confirm this action.")
await ctx.request_info("Need approval", str, request_id="approval-1")
workflow = WorkflowBuilder(start_executor=text_then_request).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_text_messages_balanced()
stream.assert_tool_calls_balanced()
# TEXT_MESSAGE_END must appear before TOOL_CALL_START
types = stream.types()
text_end_idx = types.index("TEXT_MESSAGE_END")
tool_start_idx = types.index("TOOL_CALL_START")
assert text_end_idx < tool_start_idx, (
f"TEXT_MESSAGE_END (idx={text_end_idx}) must come before TOOL_CALL_START (idx={tool_start_idx})"
)
# ──────────────────────────────────────────────────────────────────────
# 9. Text deduplication
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_skips_duplicate_text_from_snapshot() -> None:
"""Duplicate text from AgentResponse snapshot is not re-emitted."""
@executor(id="deduper")
async def deduper(message: Any, ctx: WorkflowContext[Never, Any]) -> None:
text = "Order processed successfully."
await ctx.yield_output(text)
# Snapshot repeats the same text
await ctx.yield_output(
AgentResponse(
messages=[
Message(role="user", contents=[Content.from_text("process order")]),
Message(role="assistant", contents=[Content.from_text(text)]),
]
)
)
workflow = WorkflowBuilder(start_executor=deduper).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_text_messages_balanced()
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
# Text should appear only once
assert deltas == ["Order processed successfully."]
async def test_workflow_skips_consecutive_duplicate_outputs() -> None:
"""Consecutive identical text outputs are deduplicated."""
@executor(id="repeater")
async def repeater(message: Any, ctx: WorkflowContext[Never, Any]) -> None:
text = "Done!"
await ctx.yield_output(text)
await ctx.yield_output(text)
workflow = WorkflowBuilder(start_executor=repeater).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_text_messages_balanced()
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
assert deltas == ["Done!"]
async def test_workflow_emits_distinct_consecutive_outputs() -> None:
"""Distinct text outputs are all emitted, not incorrectly deduplicated."""
@executor(id="multisayer")
async def multisayer(message: Any, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("First part. ")
await ctx.yield_output("Second part.")
workflow = WorkflowBuilder(start_executor=multisayer).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_text_messages_balanced()
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
assert deltas == ["First part. ", "Second part."]
# ──────────────────────────────────────────────────────────────────────
# 10. Workflow error handling → RUN_ERROR
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_error_emits_run_error_event() -> None:
"""Exceptions during workflow streaming produce RUN_ERROR events."""
class FailingWorkflow:
def run(self, **kwargs: Any):
async def _stream():
raise RuntimeError("workflow exploded")
yield # pragma: no cover
return _stream()
wrapper = AgentFrameworkWorkflow(workflow=cast(Any, FailingWorkflow()))
stream = await _run(wrapper, _payload())
# Should still have RUN_STARTED
stream.assert_has_type("RUN_STARTED")
# Should have RUN_ERROR
stream.assert_has_type("RUN_ERROR")
error = stream.first("RUN_ERROR")
assert "workflow exploded" in error.message
async def test_workflow_error_preserves_bookend_structure() -> None:
"""Even on error, RUN_STARTED is the first event."""
class FailingWorkflow:
def run(self, **kwargs: Any):
async def _stream():
raise ValueError("bad input")
yield # pragma: no cover
return _stream()
wrapper = AgentFrameworkWorkflow(workflow=cast(Any, FailingWorkflow()))
stream = await _run(wrapper, _payload())
types = stream.types()
assert types[0] == "RUN_STARTED"
assert "RUN_ERROR" in types
# ──────────────────────────────────────────────────────────────────────
# 11. Multi-turn request_info interrupt/resume
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_interrupt_resume_round_trip() -> None:
"""Turn 1: request_info → interrupt. Turn 2: resume → completion."""
class RequesterExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="requester")
@handler
async def start(self, message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info("Choose an option", str, request_id="choice-1")
@response_handler
async def handle_choice(self, original: str, response: str, ctx: WorkflowContext) -> None:
await ctx.yield_output(f"You chose: {response}")
workflow = WorkflowBuilder(start_executor=RequesterExecutor()).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1
stream1 = await _run(wrapper, _payload(thread_id="thread-resume", run_id="run-1"))
stream1.assert_bookends()
stream1.assert_no_run_error()
stream1.assert_tool_calls_balanced()
finished1 = stream1.last("RUN_FINISHED")
interrupt1 = finished1.model_dump().get("interrupt")
assert interrupt1, "Expected interrupt"
assert interrupt1[0]["id"] == "choice-1"
# Turn 2: resume
stream2 = await _run(
wrapper,
{
"thread_id": "thread-resume",
"run_id": "run-2",
"messages": [],
"resume": {"interrupts": [{"id": "choice-1", "value": "Option A"}]},
},
)
stream2.assert_has_run_lifecycle()
stream2.assert_no_run_error()
stream2.assert_text_messages_balanced()
# Should have the response text
deltas = [e.delta for e in stream2.get("TEXT_MESSAGE_CONTENT")]
assert any("Option A" in d for d in deltas), f"Expected 'Option A' in deltas: {deltas}"
# No interrupt after resume
finished2 = stream2.last("RUN_FINISHED")
interrupt2 = finished2.model_dump().get("interrupt")
assert not interrupt2
async def test_workflow_forwarded_props_resume() -> None:
"""CopilotKit-style forwarded_props.command.resume should resume a pending request."""
@executor(id="requester")
async def requester(message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info({"options": [{"name": "A"}]}, dict, request_id="pick")
workflow = WorkflowBuilder(start_executor=requester).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1
await _run(wrapper, _payload(thread_id="thread-fwd", run_id="run-1"))
# Turn 2 via forwarded_props
stream2 = await _run(
wrapper,
{
"thread_id": "thread-fwd",
"run_id": "run-2",
"messages": [],
"forwarded_props": {"command": {"resume": json.dumps({"name": "A"})}},
},
)
stream2.assert_bookends()
stream2.assert_no_run_error()
finished = stream2.last("RUN_FINISHED")
assert not finished.model_dump().get("interrupt")
# ──────────────────────────────────────────────────────────────────────
# 12. Empty turns with pending requests
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_empty_turn_preserves_interrupts() -> None:
"""An empty turn with a pending request still returns the interrupt without errors."""
@executor(id="requester")
async def requester(message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info({"prompt": "choose"}, dict, request_id="pick-one")
workflow = WorkflowBuilder(start_executor=requester).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1: trigger the request
await _run(wrapper, _payload(thread_id="thread-empty", run_id="run-1"))
# Turn 2: empty messages, no resume
stream2 = await _run(
wrapper,
{
"thread_id": "thread-empty",
"run_id": "run-2",
"messages": [],
},
)
stream2.assert_bookends()
stream2.assert_no_run_error()
stream2.assert_tool_calls_balanced()
# Should re-emit the pending interrupt
finished = stream2.last("RUN_FINISHED")
interrupts = finished.model_dump().get("interrupt")
assert isinstance(interrupts, list)
assert interrupts[0]["id"] == "pick-one"
# Should have TOOL_CALL events for the pending request
stream2.assert_has_type("TOOL_CALL_START")
async def test_workflow_empty_turn_no_pending_requests() -> None:
"""Empty turn with no pending requests produces clean bookends."""
@executor(id="noop")
async def noop(message: Any, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("done")
workflow = WorkflowBuilder(start_executor=noop).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Run once to completion
await _run(wrapper, _payload(thread_id="thread-empty-clean", run_id="run-1"))
# Empty turn
stream2 = await _run(
wrapper,
{
"thread_id": "thread-empty-clean",
"run_id": "run-2",
"messages": [],
},
)
stream2.assert_bookends()
stream2.assert_no_run_error()
# ──────────────────────────────────────────────────────────────────────
# 13. Usage content as CUSTOM event
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_usage_output_maps_to_custom_event() -> None:
"""Usage Content outputs are surfaced as custom usage events."""
@executor(id="usage_reporter")
async def usage_reporter(message: Any, ctx: WorkflowContext[Never, Content]) -> None:
await ctx.yield_output(
Content.from_usage({"input_token_count": 100, "output_token_count": 50, "total_token_count": 150})
)
workflow = WorkflowBuilder(start_executor=usage_reporter).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
stream.assert_no_run_error()
usage_events = [e for e in stream.get("CUSTOM") if getattr(e, "name", None) == "usage"]
assert len(usage_events) == 1
assert usage_events[0].value["input_token_count"] == 100
assert usage_events[0].value["total_token_count"] == 150
# ──────────────────────────────────────────────────────────────────────
# 14. Approval flow (Content-based request_info)
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_approval_flow_round_trip() -> None:
"""function_approval_request via request_info, then resume with approval response."""
class ApprovalExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="approval_exec")
@handler
async def start(self, message: Any, ctx: WorkflowContext) -> None:
function_call = Content.from_function_call(
call_id="refund-call",
name="submit_refund",
arguments={"order_id": "12345", "amount": "$89.99"},
)
approval_request = Content.from_function_approval_request(id="approval-1", function_call=function_call)
await ctx.request_info(approval_request, Content, request_id="approval-1")
@response_handler
async def handle_approval(self, original_request: Content, response: Content, ctx: WorkflowContext) -> None:
status = "approved" if bool(response.approved) else "rejected"
await ctx.yield_output(f"Refund {status}.")
workflow = WorkflowBuilder(start_executor=ApprovalExecutor()).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1: request approval
stream1 = await _run(wrapper, _payload(thread_id="thread-approval", run_id="run-1"))
stream1.assert_bookends()
stream1.assert_no_run_error()
finished1 = stream1.last("RUN_FINISHED")
interrupt1 = finished1.model_dump().get("interrupt")
assert interrupt1, "Expected approval interrupt"
interrupt_value = interrupt1[0]["value"]
# Turn 2: approve
stream2 = await _run(
wrapper,
{
"thread_id": "thread-approval",
"run_id": "run-2",
"messages": [],
"resume": {
"interrupts": [
{
"id": "approval-1",
"value": {
"type": "function_approval_response",
"approved": True,
"id": interrupt_value.get("id", "approval-1"),
"function_call": interrupt_value.get("function_call"),
},
}
]
},
},
)
stream2.assert_has_run_lifecycle()
stream2.assert_no_run_error()
stream2.assert_text_messages_balanced()
deltas = [e.delta for e in stream2.get("TEXT_MESSAGE_CONTENT")]
assert any("approved" in d for d in deltas)
# No more interrupt
finished2 = stream2.last("RUN_FINISHED")
assert not finished2.model_dump().get("interrupt")
# ──────────────────────────────────────────────────────────────────────
# 15. Message list request/response coercion
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_message_list_resume() -> None:
"""Resume with list[Message] payload coerces correctly into workflow response."""
class MessageRequestExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="msg_request")
@handler
async def start(self, message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info({"prompt": "Need follow-up"}, list[Message], request_id="handoff")
@response_handler
async def handle_input(self, original: dict, response: list[Message], ctx: WorkflowContext) -> None:
user_text = response[0].text if response else ""
await ctx.yield_output(f"Got: {user_text}")
workflow = WorkflowBuilder(start_executor=MessageRequestExecutor()).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1
await _run(wrapper, _payload(thread_id="thread-msg", run_id="run-1"))
# Turn 2: resume with message list
stream2 = await _run(
wrapper,
{
"thread_id": "thread-msg",
"run_id": "run-2",
"messages": [],
"resume": {
"interrupts": [
{
"id": "handoff",
"value": [
{"role": "user", "contents": [{"type": "text", "text": "Ship a replacement"}]},
],
}
]
},
},
)
stream2.assert_has_run_lifecycle()
stream2.assert_no_run_error()
stream2.assert_text_messages_balanced()
deltas = [e.delta for e in stream2.get("TEXT_MESSAGE_CONTENT")]
assert any("replacement" in d for d in deltas)
# ──────────────────────────────────────────────────────────────────────
# 16. Plain text follow-up does NOT infer interrupt response
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_plain_text_does_not_resume_pending_dict_request() -> None:
"""Plain text user follow-up should NOT be coerced into a dict response."""
@executor(id="requester")
async def requester(message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info(
{"message": "Choose a flight", "options": [{"airline": "KLM"}], "agent": "flights"},
dict,
request_id="flights-choice",
)
workflow = WorkflowBuilder(start_executor=requester).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1
await _run(wrapper, _payload(thread_id="thread-nocoerce", run_id="run-1"))
# Turn 2: plain text follow-up with request_info tool call in history
stream2 = await _run(
wrapper,
{
"thread_id": "thread-nocoerce",
"run_id": "run-2",
"messages": [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "flights-choice",
"type": "function",
"function": {"name": "request_info", "arguments": "{}"},
}
],
},
{"role": "user", "content": "I prefer KLM please"},
],
},
)
stream2.assert_bookends()
stream2.assert_no_run_error()
# Should still have the interrupt (text was not accepted as dict response)
finished = stream2.last("RUN_FINISHED")
interrupts = finished.model_dump().get("interrupt")
assert isinstance(interrupts, list)
assert interrupts[0]["id"] == "flights-choice"
# ──────────────────────────────────────────────────────────────────────
# 17. Workflow factory (thread-scoped workflows)
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_factory_thread_scoping() -> None:
"""workflow_factory creates separate workflow instances per thread_id."""
def make_workflow(thread_id: str):
@executor(id="echo")
async def echo(message: Any, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output(f"Thread: {thread_id}")
return WorkflowBuilder(start_executor=echo).build()
wrapper = AgentFrameworkWorkflow(workflow_factory=make_workflow)
stream_a = await _run(wrapper, _payload(thread_id="thread-a", run_id="run-a"))
stream_b = await _run(wrapper, _payload(thread_id="thread-b", run_id="run-b"))
stream_a.assert_bookends()
stream_b.assert_bookends()
deltas_a = [e.delta for e in stream_a.get("TEXT_MESSAGE_CONTENT")]
deltas_b = [e.delta for e in stream_b.get("TEXT_MESSAGE_CONTENT")]
assert any("thread-a" in d for d in deltas_a)
assert any("thread-b" in d for d in deltas_b)
# ──────────────────────────────────────────────────────────────────────
# 18. Multiple request_info calls in sequence
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_sequential_request_info_interrupts() -> None:
"""Two chained executors each requesting info: first triggers interrupt, resume, then second triggers interrupt.
This mirrors the subgraphs_agent pattern where separate executors handle sequential interactions.
"""
class NameRequester(Executor):
def __init__(self) -> None:
super().__init__(id="name_requester")
@handler
async def start(self, message: Any, ctx: WorkflowContext[str]) -> None:
await ctx.request_info("What's your name?", str, request_id="name-req")
@response_handler
async def handle_name(self, original: str, response: str, ctx: WorkflowContext[str]) -> None:
await ctx.send_message(response)
class DestRequester(Executor):
def __init__(self) -> None:
super().__init__(id="dest_requester")
@handler
async def start(self, message: str, ctx: WorkflowContext[str]) -> None:
self._name = message
await ctx.request_info("Where to?", str, request_id="dest-req")
@response_handler
async def handle_dest(self, original: str, response: str, ctx: WorkflowContext[str]) -> None:
await ctx.yield_output(f"Booking for {self._name} to {response}")
name_requester = NameRequester()
dest_requester = DestRequester()
workflow = WorkflowBuilder(start_executor=name_requester).add_chain([name_requester, dest_requester]).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1
stream1 = await _run(wrapper, _payload(thread_id="thread-seq", run_id="run-1"))
stream1.assert_bookends()
stream1.assert_tool_calls_balanced()
interrupt1 = stream1.last("RUN_FINISHED").model_dump().get("interrupt")
assert interrupt1[0]["id"] == "name-req"
# Turn 2: answer name → triggers second executor's request_info
stream2 = await _run(
wrapper,
{
"thread_id": "thread-seq",
"run_id": "run-2",
"messages": [],
"resume": {"interrupts": [{"id": "name-req", "value": "Alice"}]},
},
)
stream2.assert_has_run_lifecycle()
stream2.assert_tool_calls_balanced()
interrupt2 = stream2.last("RUN_FINISHED").model_dump().get("interrupt")
assert interrupt2[0]["id"] == "dest-req"
# Turn 3: answer destination → completion
stream3 = await _run(
wrapper,
{
"thread_id": "thread-seq",
"run_id": "run-3",
"messages": [],
"resume": {"interrupts": [{"id": "dest-req", "value": "Paris"}]},
},
)
stream3.assert_has_run_lifecycle()
stream3.assert_no_run_error()
stream3.assert_text_messages_balanced()
deltas = [e.delta for e in stream3.get("TEXT_MESSAGE_CONTENT")]
assert any("Alice" in d and "Paris" in d for d in deltas)
assert not stream3.last("RUN_FINISHED").model_dump().get("interrupt")
@@ -0,0 +1,72 @@
# Copyright (c) Microsoft. All rights reserved.
"""SSE parsing helpers for AG-UI HTTP round-trip tests."""
from __future__ import annotations
import json
from typing import Any
from event_stream import EventStream
def parse_sse_response(response_content: bytes) -> list[dict[str, Any]]:
"""Parse raw SSE bytes from TestClient into a list of event dicts.
Each SSE event is a ``data: {...}`` line followed by a blank line.
"""
text = response_content.decode("utf-8")
events: list[dict[str, Any]] = []
decode_errors: list[str] = []
for line in text.splitlines():
if line.startswith("data: "):
payload = line[6:]
try:
events.append(json.loads(payload))
except json.JSONDecodeError as exc:
decode_errors.append(f"payload={payload!r}, error={exc}")
continue
if decode_errors:
joined = "; ".join(decode_errors)
raise AssertionError(f"Failed to decode one or more SSE data lines: {joined}")
return events
def parse_sse_to_event_stream(response_content: bytes) -> EventStream:
"""Parse SSE bytes and wrap in EventStream for structured assertions.
Returns an EventStream over lightweight SimpleNamespace objects that
mirror AG-UI event attributes (type, message_id, tool_call_id, etc.)
so that EventStream assertion methods work.
"""
from types import SimpleNamespace
raw_events = parse_sse_response(response_content)
events: list[Any] = []
for raw in raw_events:
# Normalize camelCase keys to snake_case attributes that EventStream expects
ns = SimpleNamespace()
ns.type = raw.get("type", "")
ns.raw = raw
# Map common camelCase fields
for camel, snake in _FIELD_MAP.items():
if camel in raw:
setattr(ns, snake, raw[camel])
# Also keep camelCase as attributes for direct access
for key, value in raw.items():
if not hasattr(ns, key):
setattr(ns, key, value)
events.append(ns)
return EventStream(events)
_FIELD_MAP: dict[str, str] = {
"messageId": "message_id",
"runId": "run_id",
"threadId": "thread_id",
"toolCallId": "tool_call_id",
"toolCallName": "tool_call_name",
"toolName": "tool_call_name",
"parentMessageId": "parent_message_id",
"stepName": "step_name",
}
@@ -21,7 +21,7 @@ from agent_framework_ag_ui._client import AGUIChatClient
from agent_framework_ag_ui._http_service import AGUIHttpService
class TestableAGUIChatClient(AGUIChatClient):
class StubAGUIChatClient(AGUIChatClient):
"""Testable wrapper exposing protected helpers."""
@property
@@ -53,19 +53,19 @@ class TestAGUIChatClient:
async def test_client_initialization(self) -> None:
"""Test client initialization."""
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
assert client.http_service is not None
assert client.http_service.endpoint.startswith("http://localhost:8888")
async def test_client_context_manager(self) -> None:
"""Test client as async context manager."""
async with TestableAGUIChatClient(endpoint="http://localhost:8888/") as client:
async with StubAGUIChatClient(endpoint="http://localhost:8888/") as client:
assert client is not None
async def test_extract_state_from_messages_no_state(self) -> None:
"""Test state extraction when no state is present."""
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
messages = [
Message(role="user", text="Hello"),
Message(role="assistant", text="Hi there"),
@@ -80,7 +80,7 @@ class TestAGUIChatClient:
"""Test state extraction from last message."""
import base64
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
state_data = {"key": "value", "count": 42}
state_json = json.dumps(state_data)
@@ -104,7 +104,7 @@ class TestAGUIChatClient:
"""Test state extraction with invalid JSON."""
import base64
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
invalid_json = "not valid json"
state_b64 = base64.b64encode(invalid_json.encode("utf-8")).decode("utf-8")
@@ -123,7 +123,7 @@ class TestAGUIChatClient:
async def test_convert_messages_to_agui_format(self) -> None:
"""Test message conversion to AG-UI format."""
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
messages = [
Message(role="user", text="What is the weather?"),
Message(role="assistant", text="Let me check.", message_id="msg_123"),
@@ -140,7 +140,7 @@ class TestAGUIChatClient:
async def test_get_thread_id_from_metadata(self) -> None:
"""Test thread ID extraction from metadata."""
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
chat_options = ChatOptions(metadata={"thread_id": "existing_thread_123"})
thread_id = client.get_thread_id(chat_options)
@@ -149,7 +149,7 @@ class TestAGUIChatClient:
async def test_get_thread_id_generation(self) -> None:
"""Test automatic thread ID generation."""
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
chat_options = ChatOptions()
thread_id = client.get_thread_id(chat_options)
@@ -170,7 +170,7 @@ class TestAGUIChatClient:
for event in mock_events:
yield event
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", text="Test message")]
@@ -203,7 +203,7 @@ class TestAGUIChatClient:
for event in mock_events:
yield event
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", text="Test message")]
@@ -246,7 +246,7 @@ class TestAGUIChatClient:
for event in mock_events:
yield event
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", text="Test with tools")]
@@ -270,7 +270,7 @@ class TestAGUIChatClient:
for event in mock_events:
yield event
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", text="Test server tool execution")]
@@ -312,7 +312,7 @@ class TestAGUIChatClient:
monkeypatch.setattr("agent_framework._tools._auto_invoke_function", fake_auto_invoke)
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", text="Test server tool execution")]
@@ -348,7 +348,7 @@ class TestAGUIChatClient:
for event in mock_events:
yield event
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
chat_options = ChatOptions()
@@ -357,6 +357,81 @@ class TestAGUIChatClient:
assert response is not None
async def test_extract_state_from_empty_messages(self) -> None:
"""Empty messages list returns empty list and None state."""
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
result_messages, state = client.extract_state_from_messages([])
assert result_messages == []
assert state is None
async def test_register_server_tool_non_dict_config(self) -> None:
"""Non-dict function_invocation_configuration is a no-op."""
client = StubAGUIChatClient(
endpoint="http://localhost:8888/",
function_invocation_configuration=None, # type: ignore[arg-type]
)
# Should not raise
client._register_server_tool_placeholder("some_tool")
async def test_non_streaming_response(self, monkeypatch: MonkeyPatch) -> None:
"""Non-streaming path collects updates into ChatResponse."""
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Hello"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
for event in mock_events:
yield event
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", text="Test")]
response = await client.inner_get_response(messages=messages, options={}, stream=False)
assert response is not None
assert len(response.messages) > 0
async def test_client_tool_sets_additional_properties(self, monkeypatch: MonkeyPatch) -> None:
"""Client tool content gets agui_thread_id additional property."""
@tool
def my_tool(param: str) -> str:
"""My tool."""
return "result"
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "my_tool"},
{"type": "TOOL_CALL_ARGS", "toolCallId": "call_1", "delta": '{"param": "test"}'},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
for event in mock_events:
yield event
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", text="Test")]
updates: list[ChatResponseUpdate] = []
async for update in client._inner_get_response(messages=messages, stream=True, options={"tools": [my_tool]}):
updates.append(update)
# Find the function_call content - it should have agui_thread_id
found = False
for update in updates:
for content in update.contents:
if content.type == "function_call" and content.name == "my_tool":
assert content.additional_properties is not None
assert "agui_thread_id" in content.additional_properties
found = True
break
assert found, "Expected to find function_call content for my_tool"
async def test_interrupt_options_transmission(self, monkeypatch: MonkeyPatch) -> None:
"""Interrupt option fields are forwarded to the HTTP service."""
available_interrupts = [{"id": "req_1", "type": "request_info"}]
@@ -373,7 +448,7 @@ class TestAGUIChatClient:
for event in mock_events:
yield event
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", text="continue")]
@@ -550,3 +550,56 @@ async def test_endpoint_without_dependencies_is_accessible(build_chat_client):
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
async def test_endpoint_invalid_agent_type_raises_typeerror():
"""Passing an invalid agent type raises TypeError."""
app = FastAPI()
with pytest.raises(TypeError, match="must be SupportsAgentRun"):
add_agent_framework_fastapi_endpoint(app, agent="not_an_agent") # type: ignore[arg-type]
async def test_endpoint_encoding_failure_emits_run_error():
"""Event encoding failure emits RUN_ERROR event in the SSE stream."""
from unittest.mock import patch
class SimpleWorkflow(AgentFrameworkWorkflow):
async def run(self, input_data: dict[str, Any]):
del input_data
yield RunStartedEvent(run_id="run-1", thread_id="thread-1")
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, SimpleWorkflow(), path="/encode-fail")
client = TestClient(app)
with patch("ag_ui.encoder.EventEncoder.encode") as mock_encode:
# First call fails (the RUN_STARTED event), second call succeeds (the error event)
mock_encode.side_effect = [ValueError("encode boom"), 'data: {"type":"RUN_ERROR"}\n\n']
response = client.post("/encode-fail", json={"messages": [{"role": "user", "content": "go"}]})
assert response.status_code == 200
content = response.content.decode("utf-8")
assert "RUN_ERROR" in content
async def test_endpoint_double_encoding_failure_terminates():
"""When both event and error encoding fail, stream terminates gracefully."""
from unittest.mock import patch
class SimpleWorkflow(AgentFrameworkWorkflow):
async def run(self, input_data: dict[str, Any]):
del input_data
yield RunStartedEvent(run_id="run-1", thread_id="thread-1")
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, SimpleWorkflow(), path="/double-fail")
client = TestClient(app)
with patch("ag_ui.encoder.EventEncoder.encode") as mock_encode:
# Both calls fail - event encode and error event encode
mock_encode.side_effect = ValueError("always fails")
response = client.post("/double-fail", json={"messages": [{"role": "user", "content": "go"}]})
# Should still get 200 (SSE stream), just with no events
assert response.status_code == 200
@@ -0,0 +1,215 @@
# Copyright (c) Microsoft. All rights reserved.
"""HTTP round-trip tests: POST → SSE bytes → parse → validate event sequence.
These tests exercise the full HTTP pipeline using FastAPI TestClient,
parsing the raw SSE byte stream and validating through EventStream assertions.
"""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content, WorkflowBuilder, WorkflowContext, executor
from conftest import StubAgent
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sse_helpers import parse_sse_response, parse_sse_to_event_stream
from typing_extensions import Never
from agent_framework_ag_ui import AgentFrameworkAgent, AgentFrameworkWorkflow, add_agent_framework_fastapi_endpoint
def _build_app_with_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> FastAPI:
stub = StubAgent(updates=updates)
agent = AgentFrameworkAgent(agent=stub, **kwargs)
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, agent)
return app
def _build_app_with_workflow(workflow_builder: WorkflowBuilder) -> FastAPI:
workflow = workflow_builder.build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, wrapper)
return app
USER_PAYLOAD: dict[str, Any] = {
"messages": [{"role": "user", "content": "Hello"}],
"threadId": "thread-http",
"runId": "run-http",
}
# ── Agentic chat SSE round-trip ──
def test_agentic_chat_sse_round_trip() -> None:
"""Full HTTP round-trip: POST → SSE bytes → parse → validate event sequence."""
app = _build_app_with_agent(
[
AgentResponseUpdate(contents=[Content.from_text(text="Hi there!")], role="assistant"),
]
)
client = TestClient(app)
response = client.post("/", json=USER_PAYLOAD)
assert response.status_code == 200
assert "text/event-stream" in response.headers["content-type"]
stream = parse_sse_to_event_stream(response.content)
stream.assert_bookends()
stream.assert_text_messages_balanced()
stream.assert_no_run_error()
stream.assert_ordered_types(
[
"RUN_STARTED",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_END",
"MESSAGES_SNAPSHOT",
"RUN_FINISHED",
]
)
# ── Tool call SSE round-trip ──
def test_tool_call_sse_round_trip() -> None:
"""Tool call events survive SSE encoding/parsing round-trip."""
app = _build_app_with_agent(
[
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's warm!")],
role="assistant",
),
]
)
client = TestClient(app)
response = client.post("/", json=USER_PAYLOAD)
stream = parse_sse_to_event_stream(response.content)
stream.assert_bookends()
stream.assert_tool_calls_balanced()
stream.assert_text_messages_balanced()
# Verify tool call details survive SSE encoding
start = stream.first("TOOL_CALL_START")
assert start.tool_call_name == "get_weather"
assert start.tool_call_id == "call-1"
# ── SSE encoding fidelity ──
def test_sse_event_encoding_fidelity() -> None:
"""Every event from agent.run() produces a valid SSE data: line that round-trips."""
app = _build_app_with_agent(
[
AgentResponseUpdate(contents=[Content.from_text(text="Hello world")], role="assistant"),
]
)
client = TestClient(app)
response = client.post("/", json=USER_PAYLOAD)
raw_events = parse_sse_response(response.content)
assert len(raw_events) > 0, "No SSE events parsed"
# Every event should have a 'type' field
for event in raw_events:
assert "type" in event, f"Event missing 'type': {event}"
# Event types should include the expected ones
event_types = [e["type"] for e in raw_events]
assert "RUN_STARTED" in event_types
assert "RUN_FINISHED" in event_types
# ── camelCase request field acceptance ──
def test_camel_case_request_fields_accepted() -> None:
"""Request with camelCase fields (runId, threadId) is correctly parsed."""
app = _build_app_with_agent(
[
AgentResponseUpdate(contents=[Content.from_text(text="ok")], role="assistant"),
]
)
client = TestClient(app)
response = client.post(
"/",
json={
"messages": [{"role": "user", "content": "hi"}],
"runId": "camel-run",
"threadId": "camel-thread",
},
)
assert response.status_code == 200
stream = parse_sse_to_event_stream(response.content)
stream.assert_bookends()
# ── Workflow SSE round-trip ──
def test_workflow_sse_round_trip() -> None:
"""Workflow events survive SSE encoding/parsing."""
@executor(id="greeter")
async def greeter(message: Any, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("Hello from workflow!")
app = _build_app_with_workflow(WorkflowBuilder(start_executor=greeter))
client = TestClient(app)
response = client.post("/", json=USER_PAYLOAD)
assert response.status_code == 200
stream = parse_sse_to_event_stream(response.content)
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_text_messages_balanced()
stream.assert_has_type("STEP_STARTED")
# ── Error handling ──
def test_empty_messages_returns_valid_sse() -> None:
"""Empty messages list still returns a valid SSE stream with bookends."""
app = _build_app_with_agent(
[
AgentResponseUpdate(contents=[Content.from_text(text="ok")], role="assistant"),
]
)
client = TestClient(app)
response = client.post("/", json={"messages": []})
assert response.status_code == 200
stream = parse_sse_to_event_stream(response.content)
stream.assert_bookends()
def test_sse_response_headers() -> None:
"""SSE response has correct headers for event streaming."""
app = _build_app_with_agent(
[
AgentResponseUpdate(contents=[Content.from_text(text="ok")], role="assistant"),
]
)
client = TestClient(app)
response = client.post("/", json=USER_PAYLOAD)
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
assert response.headers.get("cache-control") == "no-cache"
@@ -868,6 +868,648 @@ def test_agui_messages_to_snapshot_format_basic():
assert result[1]["content"] == "Hi there"
# ── Tool history sanitization edge cases ──
def test_sanitize_multiple_approvals_and_logic():
"""Two function_approval_response contents: True + False → False overall."""
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
assistant_msg = Message(
role="assistant",
contents=[
Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
Content.from_function_call(call_id="c2", name="confirm_changes", arguments='{"function_call_id":"c1"}'),
],
)
user_msg = Message(
role="user",
contents=[
Content.from_function_approval_response(
approved=True,
id="a1",
function_call=Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
),
Content.from_function_approval_response(
approved=False,
id="a2",
function_call=Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
),
],
)
result = _sanitize_tool_history([assistant_msg, user_msg])
# Both approvals should be preserved in user message
assert any(msg.role == "user" for msg in result)
def test_sanitize_pending_tool_skip_on_user_followup():
"""User text message after assistant tool call injects synthetic skipped results."""
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
assistant_msg = Message(
role="assistant",
contents=[Content.from_function_call(call_id="c1", name="get_weather", arguments="{}")],
)
user_msg = Message(
role="user",
contents=[Content.from_text(text="Actually, never mind")],
)
result = _sanitize_tool_history([assistant_msg, user_msg])
# Should have: assistant, synthetic tool result, user
tool_results = [m for m in result if m.role == "tool"]
assert len(tool_results) == 1
assert "skipped" in str(tool_results[0].contents[0].result).lower()
def test_sanitize_tool_result_clears_pending_confirm():
"""Tool result for pending confirm_changes call_id clears pending state."""
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
assistant_msg = Message(
role="assistant",
contents=[
Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
],
)
tool_msg = Message(
role="tool",
contents=[Content.from_function_result(call_id="c1", result="done")],
)
result = _sanitize_tool_history([assistant_msg, tool_msg])
assert len(result) == 2
assert result[1].role == "tool"
def test_sanitize_non_standard_role_resets_state():
"""System message between assistant+user resets pending tool state."""
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
assistant_msg = Message(
role="assistant",
contents=[Content.from_function_call(call_id="c1", name="get_weather", arguments="{}")],
)
system_msg = Message(role="system", contents=[Content.from_text(text="System update")])
user_msg = Message(role="user", contents=[Content.from_text(text="Continue")])
result = _sanitize_tool_history([assistant_msg, system_msg, user_msg])
# System message should reset pending state, so no synthetic tool results
tool_results = [m for m in result if m.role == "tool"]
assert len(tool_results) == 0
def test_sanitize_json_confirm_changes_response():
"""User sends JSON text with 'accepted' after confirm_changes."""
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
assistant_msg = Message(
role="assistant",
contents=[
Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
Content.from_function_call(call_id="c2", name="confirm_changes", arguments='{"function_call_id":"c1"}'),
],
)
# Note: confirm_changes is filtered, so c2 won't be in pending_tool_call_ids
# But c1 will remain pending. User message with JSON accepted text doesn't match
# confirm_changes path since pending_confirm_changes_id was reset.
user_msg = Message(
role="user",
contents=[Content.from_text(text=json.dumps({"accepted": True}))],
)
result = _sanitize_tool_history([assistant_msg, user_msg])
# Should still process without errors
assert len(result) >= 1
# ── Deduplication edge cases ──
def test_deduplicate_tool_results():
"""Duplicate tool results for same call_id are deduplicated."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
msg1 = Message(role="tool", contents=[Content.from_function_result(call_id="c1", result="first")])
msg2 = Message(role="tool", contents=[Content.from_function_result(call_id="c1", result="second")])
result = _deduplicate_messages([msg1, msg2])
assert len(result) == 1
def test_deduplicate_assistant_tool_calls():
"""Duplicate assistant messages with same tool_calls are deduplicated."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
msg1 = Message(
role="assistant",
contents=[Content.from_function_call(call_id="c1", name="fn", arguments="{}")],
)
msg2 = Message(
role="assistant",
contents=[Content.from_function_call(call_id="c1", name="fn", arguments="{}")],
)
result = _deduplicate_messages([msg1, msg2])
assert len(result) == 1
def test_deduplicate_general_messages():
"""Duplicate general user messages are deduplicated."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")])
result = _deduplicate_messages([msg1, msg2])
assert len(result) == 1
def test_deduplicate_replaces_empty_tool_result():
"""Empty tool result is replaced by later non-empty result."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
msg1 = Message(role="tool", contents=[Content.from_function_result(call_id="c1", result="")])
msg2 = Message(role="tool", contents=[Content.from_function_result(call_id="c1", result="actual result")])
result = _deduplicate_messages([msg1, msg2])
assert len(result) == 1
assert result[0].contents[0].result == "actual result"
# ── Multimodal & content conversion edge cases ──
def test_convert_agui_content_unknown_source_type_fallback():
"""Unknown source type falls back to url/data/id fields."""
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
part = {
"type": "image",
"source": {"type": "custom", "url": "https://example.com/img.png"},
}
result = _parse_multimodal_media_part(part)
assert result is not None
assert result.uri == "https://example.com/img.png"
def test_convert_agui_content_data_uri_prefix():
"""base64 data starting with 'data:' is treated as data URI."""
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
part = {
"type": "image",
"source": {"type": "base64", "data": "data:image/png;base64,abc", "mimeType": "image/png"},
}
result = _parse_multimodal_media_part(part)
assert result is not None
assert result.uri == "data:image/png;base64,abc"
def test_convert_agui_content_binary_id():
"""Source with 'id' field creates ag-ui:// URI."""
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
part = {
"type": "image",
"source": {"type": "id", "id": "file123"},
}
result = _parse_multimodal_media_part(part)
assert result is not None
assert result.uri == "ag-ui://binary/file123"
def test_convert_agui_content_string_items_in_list():
"""String items in content list create text Content."""
from agent_framework_ag_ui._message_adapters import _convert_agui_content_to_framework
result = _convert_agui_content_to_framework(["hello", "world"])
assert len(result) == 2
assert result[0].text == "hello"
assert result[1].text == "world"
def test_convert_agui_content_non_dict_non_str_items():
"""Non-dict/non-str items in list are stringified."""
from agent_framework_ag_ui._message_adapters import _convert_agui_content_to_framework
result = _convert_agui_content_to_framework([123, None])
assert len(result) == 2
assert result[0].text == "123"
assert result[1].text == "None"
def test_convert_agui_content_unknown_part_type_with_text():
"""Unknown part type with 'text' key extracts the text."""
from agent_framework_ag_ui._message_adapters import _convert_agui_content_to_framework
result = _convert_agui_content_to_framework([{"type": "widget", "text": "hi"}])
assert len(result) == 1
assert result[0].text == "hi"
def test_convert_agui_content_unknown_part_type_without_text():
"""Unknown part type without 'text' key stringifies the dict."""
from agent_framework_ag_ui._message_adapters import _convert_agui_content_to_framework
result = _convert_agui_content_to_framework([{"type": "widget", "data": 42}])
assert len(result) == 1
assert "widget" in result[0].text
def test_convert_agui_content_none():
"""None content returns empty list."""
from agent_framework_ag_ui._message_adapters import _convert_agui_content_to_framework
result = _convert_agui_content_to_framework(None)
assert result == []
def test_convert_agui_content_non_str_non_list_non_none():
"""Non-string, non-list, non-None content is stringified."""
from agent_framework_ag_ui._message_adapters import _convert_agui_content_to_framework
result = _convert_agui_content_to_framework(42)
assert len(result) == 1
assert result[0].text == "42"
# ── Snapshot normalization edge cases ──
def test_snapshot_input_image_to_binary():
"""input_image type is normalized to binary in snapshot."""
result = agui_messages_to_snapshot_format(
[
{
"role": "user",
"content": [
{"type": "input_image", "source": {"type": "url", "url": "https://example.com/img.png"}},
],
}
]
)
assert isinstance(result[0]["content"], list)
assert result[0]["content"][0]["type"] == "binary"
def test_snapshot_mime_type_snake_case():
"""mime_type (snake_case) is normalized to mimeType."""
result = agui_messages_to_snapshot_format(
[
{
"role": "user",
"content": [
{"type": "text", "text": "Caption", "mime_type": "text/plain"},
{
"type": "image",
"source": {"type": "url", "url": "https://x.com/a.png", "mime_type": "image/png"},
},
],
}
]
)
content = result[0]["content"]
assert isinstance(content, list)
# The text part should have mimeType added
text_part = content[0]
assert text_part.get("mimeType") == "text/plain"
def test_snapshot_text_only_list_collapsed():
"""List of only text parts is collapsed to string."""
result = agui_messages_to_snapshot_format(
[{"role": "user", "content": [{"type": "text", "text": "Hello"}, {"type": "text", "text": " World"}]}]
)
assert result[0]["content"] == "Hello World"
def test_snapshot_legacy_binary_data_and_id():
"""Legacy binary part with data and id fields."""
result = agui_messages_to_snapshot_format(
[
{
"role": "user",
"content": [
{"type": "text", "text": "Caption"},
{"type": "binary", "data": "base64data", "id": "file1", "mimeType": "image/png"},
],
}
]
)
content = result[0]["content"]
assert isinstance(content, list)
binary_part = content[1]
assert binary_part["type"] == "binary"
assert binary_part["data"] == "base64data"
assert binary_part["id"] == "file1"
# ── Message conversion edge cases ──
def test_agui_tool_message_action_execution_id_fallback():
"""Tool message with actionExecutionId but no tool_call_id."""
messages = agui_messages_to_agent_framework(
[
{
"role": "tool",
"content": "result data",
"actionExecutionId": "action_1",
}
]
)
assert len(messages) == 1
assert messages[0].contents[0].type == "function_result"
assert messages[0].contents[0].call_id == "action_1"
def test_agui_tool_message_result_key_instead_of_content():
"""Tool message with 'result' key instead of 'content'."""
messages = agui_messages_to_agent_framework(
[
{
"role": "tool",
"result": "the result",
"toolCallId": "c1",
}
]
)
assert len(messages) == 1
assert messages[0].contents[0].result == "the result"
def test_agui_tool_message_dict_content():
"""Tool message with dict content."""
messages = agui_messages_to_agent_framework(
[
{
"role": "tool",
"content": {"key": "value"},
"toolCallId": "c1",
}
]
)
assert len(messages) == 1
# Dict content as approval check: no 'accepted' key, so it's a regular tool result
assert messages[0].contents[0].type == "function_result"
def test_agui_tool_message_list_content():
"""Tool message with list content."""
messages = agui_messages_to_agent_framework(
[
{
"role": "tool",
"content": ["item1", "item2"],
"toolCallId": "c1",
}
]
)
assert len(messages) == 1
assert messages[0].contents[0].type == "function_result"
def test_agui_action_execution_id_without_role():
"""Message with actionExecutionId but no role maps to tool."""
messages = agui_messages_to_agent_framework(
[
{
"actionExecutionId": "action_1",
"result": "tool result",
}
]
)
assert len(messages) == 1
assert messages[0].role == "tool"
assert messages[0].contents[0].call_id == "action_1"
def test_agui_non_dict_tool_call_skipped():
"""Non-dict tool_call entries in tool_calls array are skipped."""
messages = agui_messages_to_agent_framework(
[
{
"role": "assistant",
"content": "",
"tool_calls": [
"not_a_dict",
{
"id": "call_1",
"type": "function",
"function": {"name": "fn", "arguments": "{}"},
},
],
}
]
)
assert len(messages) == 1
func_calls = [c for c in messages[0].contents if c.type == "function_call"]
assert len(func_calls) == 1
def test_agui_empty_content_default():
"""Message with empty/null content gets default empty text."""
messages = agui_messages_to_agent_framework([{"role": "user"}])
assert len(messages) == 1
assert len(messages[0].contents) == 1
assert messages[0].contents[0].text == ""
def test_agui_dict_tool_msg_without_tool_call_id():
"""Dict tool message missing toolCallId gets empty string."""
result = agui_messages_to_snapshot_format([{"role": "tool", "content": "result"}])
assert len(result) == 1
assert result[0].get("toolCallId") == ""
def test_snapshot_argument_serialization_none():
"""None arguments in tool_calls are serialized to empty string."""
result = agui_messages_to_snapshot_format(
[
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "c1", "type": "function", "function": {"name": "fn", "arguments": None}},
],
}
]
)
tc = result[0]["tool_calls"][0]
assert tc["function"]["arguments"] == ""
def test_snapshot_argument_serialization_object():
"""Object arguments in tool_calls are JSON-serialized."""
result = agui_messages_to_snapshot_format(
[
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "c1", "type": "function", "function": {"name": "fn", "arguments": {"key": "val"}}},
],
}
]
)
tc = result[0]["tool_calls"][0]
assert tc["function"]["arguments"] == '{"key": "val"}'
def test_snapshot_tool_call_id_normalization():
"""tool_call_id is normalized to toolCallId in snapshot."""
result = agui_messages_to_snapshot_format([{"role": "tool", "content": "result", "tool_call_id": "c1"}])
assert result[0].get("toolCallId") == "c1"
assert "tool_call_id" not in result[0]
def test_agui_to_framework_dict_tool_msg_without_tool_call_id():
"""Dict tool message in agent_framework_messages_to_agui without toolCallId."""
result = agent_framework_messages_to_agui(
[{"role": "tool", "content": "result"}] # type: ignore[list-item]
)
assert len(result) == 1
assert result[0].get("toolCallId") == ""
def test_snapshot_none_content():
"""None content is normalized to empty string."""
result = agui_messages_to_snapshot_format([{"role": "user", "content": None}])
assert result[0]["content"] == ""
def test_sanitize_confirm_changes_with_approval_accepted():
"""Approval for pending confirm_changes creates synthetic result."""
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
# Create assistant with both a real tool and confirm_changes
assistant_msg = Message(
role="assistant",
contents=[
Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
Content.from_function_call(call_id="c2", name="confirm_changes", arguments='{"function_call_id":"c1"}'),
],
)
# Note: confirm_changes gets filtered out, so pending_confirm_changes_id becomes None.
# The test verifies the filtering path works without error.
user_msg = Message(
role="user",
contents=[
Content.from_function_approval_response(
approved=True,
id="a1",
function_call=Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
),
],
)
result = _sanitize_tool_history([assistant_msg, user_msg])
# Should process without errors; confirm_changes is filtered from assistant msg
assert len(result) >= 1
def test_sanitize_json_accepted_text_for_pending_confirm():
"""JSON text with 'accepted' field for non-filtered confirm_changes path."""
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
# Create an assistant with a tool call that requires a result
assistant_msg = Message(
role="assistant",
contents=[
Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
],
)
# A tool result arrives, then a user message
tool_msg = Message(
role="tool",
contents=[Content.from_function_result(call_id="c1", result="done")],
)
user_msg = Message(
role="user",
contents=[Content.from_text(text="Continue please")],
)
result = _sanitize_tool_history([assistant_msg, tool_msg, user_msg])
# Should have: assistant, tool result, user
assert len(result) == 3
def test_parse_multimodal_media_part_no_data_no_url():
"""Part with no url, data, or id returns None."""
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
result = _parse_multimodal_media_part({"type": "image"})
assert result is None
def test_parse_multimodal_media_part_binary_source_type():
"""Source with type='binary' extracts data field."""
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
result = _parse_multimodal_media_part(
{"type": "image", "source": {"type": "binary", "data": "data:image/png;base64,abc"}}
)
assert result is not None
assert result.uri == "data:image/png;base64,abc"
def test_snapshot_non_dict_item_in_content_list():
"""Non-dict items in content list are stringified."""
result = agui_messages_to_snapshot_format([{"role": "user", "content": [42, "text"]}])
# Text-only after stringification means collapsed to string
assert isinstance(result[0]["content"], str)
def test_snapshot_non_dict_tool_call_skipped():
"""Non-dict entries in tool_calls are skipped during argument serialization."""
result = agui_messages_to_snapshot_format(
[
{
"role": "assistant",
"content": "",
"tool_calls": [
"not_a_dict",
{"id": "c1", "type": "function", "function": {"name": "fn", "arguments": "{}"}},
],
}
]
)
# Should not error
assert len(result) == 1
def test_snapshot_tool_call_without_function_payload():
"""tool_call dict without function payload is skipped."""
result = agui_messages_to_snapshot_format(
[
{
"role": "assistant",
"content": "",
"tool_calls": [{"id": "c1", "type": "function"}],
}
]
)
assert len(result) == 1
def test_agui_to_framework_action_name_without_role():
"""Message with actionName but no explicit role maps to tool."""
messages = agui_messages_to_agent_framework([{"actionName": "get_weather", "result": "Sunny", "toolCallId": "c1"}])
assert len(messages) == 1
assert messages[0].role == "tool"
def test_agui_to_framework_tool_message_content_none():
"""Tool message with content=None uses result field fallback."""
messages = agui_messages_to_agent_framework(
[{"role": "tool", "content": None, "result": "fallback_result", "toolCallId": "c1"}]
)
assert len(messages) == 1
assert messages[0].contents[0].result == "fallback_result"
def test_agui_fresh_approval_is_still_processed():
"""A fresh approval (no assistant response after it) must still produce function_approval_response.
@@ -0,0 +1,332 @@
# Copyright (c) Microsoft. All rights reserved.
"""Multi-turn conversation tests: POST → collect events → extract snapshot → POST again.
These tests catch round-trip fidelity bugs: if MessagesSnapshotEvent produces a
malformed message list, the second turn will fail during normalize_agui_input_messages()
or produce incorrect behavior.
"""
from __future__ import annotations
import json
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sse_helpers import parse_sse_response, parse_sse_to_event_stream
from agent_framework_ag_ui import AgentFrameworkAgent, add_agent_framework_fastapi_endpoint
def _build_app_with_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> FastAPI:
stub = StubAgent(updates=updates)
agent = AgentFrameworkAgent(agent=stub, **kwargs)
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, agent)
return app
def _extract_snapshot_messages(response_content: bytes) -> list[dict[str, Any]]:
"""Extract the latest MessagesSnapshotEvent.messages from SSE response bytes."""
raw_events = parse_sse_response(response_content)
snapshot_msgs: list[dict[str, Any]] | None = None
for event in raw_events:
if event.get("type") == "MESSAGES_SNAPSHOT":
snapshot_msgs = event.get("messages", [])
assert snapshot_msgs is not None, "No MESSAGES_SNAPSHOT event found"
return snapshot_msgs
# ── Basic multi-turn chat ──
def test_basic_multi_turn_chat() -> None:
"""Turn 1: user→assistant. Turn 2: user→assistant with prior history from snapshot."""
app = _build_app_with_agent(
[
AgentResponseUpdate(contents=[Content.from_text(text="Hello! How can I help?")], role="assistant"),
]
)
client = TestClient(app)
# Turn 1
resp1 = client.post(
"/",
json={
"messages": [{"role": "user", "content": "Hi there"}],
"threadId": "thread-multi",
"runId": "run-1",
},
)
assert resp1.status_code == 200
stream1 = parse_sse_to_event_stream(resp1.content)
stream1.assert_bookends()
stream1.assert_text_messages_balanced()
# Extract snapshot messages from turn 1
snapshot_messages = _extract_snapshot_messages(resp1.content)
# Turn 2: send snapshot messages + new user message
turn2_messages = list(snapshot_messages) + [{"role": "user", "content": "Tell me more"}]
resp2 = client.post(
"/",
json={
"messages": turn2_messages,
"threadId": "thread-multi",
"runId": "run-2",
},
)
assert resp2.status_code == 200
stream2 = parse_sse_to_event_stream(resp2.content)
stream2.assert_bookends()
stream2.assert_text_messages_balanced()
stream2.assert_no_run_error()
# ── Tool call history round-trip ──
def test_tool_call_history_round_trips() -> None:
"""Turn 1: tool call + result. Turn 2: snapshot messages correctly reconstruct tool history."""
app = _build_app_with_agent(
[
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's warm!")],
role="assistant",
),
]
)
client = TestClient(app)
# Turn 1
resp1 = client.post(
"/",
json={
"messages": [{"role": "user", "content": "What's the weather?"}],
"threadId": "thread-tool-multi",
"runId": "run-1",
},
)
assert resp1.status_code == 200
stream1 = parse_sse_to_event_stream(resp1.content)
stream1.assert_tool_calls_balanced()
# Extract snapshot and verify it has tool history
snapshot_messages = _extract_snapshot_messages(resp1.content)
roles = [m.get("role") for m in snapshot_messages]
assert "tool" in roles or "assistant" in roles, f"Expected tool/assistant messages in snapshot, got: {roles}"
# Turn 2: send snapshot + new question
turn2_messages = list(snapshot_messages) + [{"role": "user", "content": "What about tomorrow?"}]
resp2 = client.post(
"/",
json={
"messages": turn2_messages,
"threadId": "thread-tool-multi",
"runId": "run-2",
},
)
assert resp2.status_code == 200
stream2 = parse_sse_to_event_stream(resp2.content)
stream2.assert_bookends()
stream2.assert_no_run_error()
# ── Approval interrupt/resume round-trip ──
async def test_approval_interrupt_resume_round_trip() -> None:
"""Turn 1: approval request → interrupt with confirm_changes. Turn 2: confirm_changes result → confirmation text.
The confirm_changes flow uses a specific message format that bypasses the agent
and directly emits a confirmation text message.
"""
from event_stream import EventStream
steps = [{"description": "Execute task", "status": "enabled"}]
# Build agent with predictive state and confirmation
stub = StubAgent(
updates=[
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="generate_task_steps",
call_id="call-steps",
arguments=json.dumps({"steps": steps}),
)
],
role="assistant",
),
]
)
agent = AgentFrameworkAgent(
agent=stub,
state_schema={"tasks": {"type": "array"}},
predict_state_config={"tasks": {"tool": "generate_task_steps", "tool_argument": "steps"}},
require_confirmation=True,
)
# Turn 1
events1 = [
e
async for e in agent.run(
{
"thread_id": "thread-approval-multi",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan my tasks"}],
"state": {"tasks": []},
}
)
]
stream1 = EventStream(events1)
stream1.assert_bookends()
stream1.assert_tool_calls_balanced()
# Should have interrupt with function_approval_request
finished1 = stream1.last("RUN_FINISHED")
interrupt1 = finished1.model_dump().get("interrupt")
assert interrupt1, "Expected interrupt in RUN_FINISHED"
# Verify confirm_changes tool call was emitted
tool_starts = stream1.get("TOOL_CALL_START")
tool_names = [getattr(s, "tool_call_name", None) for s in tool_starts]
assert "confirm_changes" in tool_names, f"Expected confirm_changes in tool calls, got {tool_names}"
# Turn 2: Direct confirm_changes response (the way CopilotKit sends it)
# Construct the messages as CopilotKit would - with the confirm_changes tool call
# and a tool result
confirm_tool = [s for s in tool_starts if getattr(s, "tool_call_name", None) == "confirm_changes"][0]
confirm_id = confirm_tool.tool_call_id
confirm_args = None
for e in stream1.get("TOOL_CALL_ARGS"):
if e.tool_call_id == confirm_id:
confirm_args = e.delta
break
turn2_messages = [
{"role": "user", "content": "Plan my tasks"},
{
"role": "assistant",
"tool_calls": [
{
"id": confirm_id,
"type": "function",
"function": {"name": "confirm_changes", "arguments": confirm_args or "{}"},
},
],
},
{
"role": "tool",
"toolCallId": confirm_id,
"content": json.dumps({"accepted": True, "steps": steps}),
},
]
events2 = [
e
async for e in agent.run(
{
"thread_id": "thread-approval-multi",
"run_id": "run-2",
"messages": turn2_messages,
"state": {"tasks": []},
}
)
]
stream2 = EventStream(events2)
stream2.assert_bookends()
stream2.assert_text_messages_balanced()
stream2.assert_no_run_error()
# Turn 2 should have confirmation text (the approval handler generates it)
text_events = stream2.get("TEXT_MESSAGE_CONTENT")
assert text_events, "Expected confirmation text message in turn 2"
# Turn 2 should NOT have interrupt (approval completed)
finished2 = stream2.last("RUN_FINISHED")
interrupt2 = finished2.model_dump().get("interrupt")
assert not interrupt2, f"Expected no interrupt after approval, got {interrupt2}"
# ── Workflow interrupt/resume round-trip ──
# Note: Workflow tests use async agent.run() directly instead of HTTP TestClient
# because the sync TestClient runs in a different event loop, which conflicts
# with the workflow's asyncio Queue.
async def test_workflow_interrupt_resume_round_trip() -> None:
"""Turn 1: workflow request_info → interrupt. Turn 2: resume → completion."""
from event_stream import EventStream
from agent_framework_ag_ui_examples.agents.subgraphs_agent import subgraphs_agent
agent = subgraphs_agent()
# Turn 1: initial request → flight interrupt
events1 = [
event
async for event in agent.run(
{
"messages": [{"role": "user", "content": "Plan a trip to SF"}],
"thread_id": "thread-wf-multi",
"run_id": "run-1",
}
)
]
stream1 = EventStream(events1)
stream1.assert_bookends()
stream1.assert_no_run_error()
finished1 = stream1.last("RUN_FINISHED")
interrupt1 = finished1.model_dump().get("interrupt")
assert interrupt1, "Expected flight interrupt"
assert interrupt1[0]["value"]["agent"] == "flights"
# Turn 2: resume with flight selection
events2 = [
event
async for event in agent.run(
{
"messages": [],
"thread_id": "thread-wf-multi",
"run_id": "run-2",
"resume": {
"interrupts": [
{
"id": interrupt1[0]["id"],
"value": json.dumps(
{
"airline": "United",
"departure": "Amsterdam (AMS)",
"arrival": "San Francisco (SFO)",
"price": "$720",
"duration": "12h 15m",
}
),
}
],
},
}
)
]
stream2 = EventStream(events2)
stream2.assert_bookends()
stream2.assert_no_run_error()
# Should now have hotel interrupt
finished2 = stream2.last("RUN_FINISHED")
interrupt2 = finished2.model_dump().get("interrupt")
assert interrupt2, "Expected hotel interrupt"
assert interrupt2[0]["value"]["agent"] == "hotels"
@@ -0,0 +1,122 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for _run_common.py edge cases."""
from agent_framework import Content
from agent_framework_ag_ui._run_common import (
FlowState,
_emit_tool_result,
_extract_resume_payload,
_normalize_resume_interrupts,
)
class TestNormalizeResumeInterrupts:
"""Tests for _normalize_resume_interrupts edge cases."""
def test_plain_list_of_dicts(self):
"""Resume payload as a plain list of interrupt dicts."""
result = _normalize_resume_interrupts([{"id": "x", "value": "y"}])
assert result == [{"id": "x", "value": "y"}]
def test_dict_with_singular_interrupt_key(self):
"""Resume dict using 'interrupt' (singular) instead of 'interrupts'."""
result = _normalize_resume_interrupts({"interrupt": [{"id": "x", "value": "y"}]})
assert result == [{"id": "x", "value": "y"}]
def test_dict_without_interrupts_key_wraps_as_candidate(self):
"""Resume dict without interrupts/interrupt key wraps the dict itself."""
result = _normalize_resume_interrupts({"id": "x", "value": "y"})
assert result == [{"id": "x", "value": "y"}]
def test_non_dict_items_in_list_are_skipped(self):
"""Non-dict items in candidate list are silently skipped."""
result = _normalize_resume_interrupts([None, "string", {"id": "x", "value": "y"}])
assert result == [{"id": "x", "value": "y"}]
def test_items_missing_id_are_skipped(self):
"""Dict items without any id field are skipped."""
result = _normalize_resume_interrupts([{"name": "test"}])
assert result == []
def test_response_key_used_as_value(self):
"""'response' key is used as value when 'value' is absent."""
result = _normalize_resume_interrupts([{"id": "x", "response": "approved"}])
assert result == [{"id": "x", "value": "approved"}]
def test_neither_value_nor_response_uses_remaining_fields(self):
"""When neither 'value' nor 'response' key exists, remaining fields become value."""
result = _normalize_resume_interrupts([{"id": "x", "extra": "data", "more": 42}])
assert result == [{"id": "x", "value": {"extra": "data", "more": 42}}]
def test_none_payload_returns_empty(self):
"""None resume payload returns empty list."""
assert _normalize_resume_interrupts(None) == []
def test_non_dict_non_list_returns_empty(self):
"""Non-dict, non-list payload returns empty list."""
assert _normalize_resume_interrupts(42) == []
def test_interrupt_id_key_used_as_id(self):
"""interruptId key is accepted as identifier."""
result = _normalize_resume_interrupts([{"interruptId": "abc", "value": "yes"}])
assert result == [{"id": "abc", "value": "yes"}]
def test_tool_call_id_key_used_as_id(self):
"""toolCallId key is accepted as identifier."""
result = _normalize_resume_interrupts([{"toolCallId": "tc1", "value": "done"}])
assert result == [{"id": "tc1", "value": "done"}]
class TestExtractResumePayload:
"""Tests for _extract_resume_payload edge cases."""
def test_forwarded_props_resume_not_nested_in_command(self):
"""forwarded_props.resume (not nested in command) is extracted."""
result = _extract_resume_payload({"forwarded_props": {"resume": "data"}})
assert result == "data"
def test_forwarded_props_not_dict_returns_none(self):
"""Non-dict forwarded_props returns None."""
result = _extract_resume_payload({"forwarded_props": "string"})
assert result is None
def test_resume_key_has_priority(self):
"""Direct resume key takes priority over forwarded_props."""
result = _extract_resume_payload({"resume": "direct", "forwarded_props": {"resume": "fp"}})
assert result == "direct"
def test_no_resume_at_all(self):
"""No resume key anywhere returns None."""
result = _extract_resume_payload({"messages": []})
assert result is None
def test_forwarded_props_camelcase(self):
"""camelCase forwardedProps is also supported."""
result = _extract_resume_payload({"forwardedProps": {"resume": "camel"}})
assert result == "camel"
class TestEmitToolResult:
"""Tests for _emit_tool_result edge cases."""
def test_tool_result_without_call_id_returns_empty(self):
"""Tool result Content without call_id returns empty event list."""
content = Content.from_function_result(call_id=None, result="some result")
flow = FlowState()
events = _emit_tool_result(content, flow)
assert events == []
def test_tool_result_closes_open_text_message(self):
"""Tool result closes any open text message (issue #3568 fix)."""
content = Content.from_function_result(call_id="call_1", result="done")
flow = FlowState(message_id="msg_1", accumulated_text="Hello")
events = _emit_tool_result(content, flow)
event_types = [e.type for e in events]
assert "TOOL_CALL_END" in event_types
assert "TOOL_CALL_RESULT" in event_types
assert "TEXT_MESSAGE_END" in event_types
assert flow.message_id is None
assert flow.accumulated_text == ""
@@ -3,12 +3,14 @@
"""Tests for native workflow AG-UI runner."""
import json
from enum import Enum
from types import SimpleNamespace
from typing import Any, cast
from ag_ui.core import EventType, StateSnapshotEvent
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
Content,
Executor,
Message,
@@ -22,8 +24,25 @@ from agent_framework import (
from typing_extensions import Never
from agent_framework_ag_ui._workflow_run import (
_coerce_content,
_coerce_json_value,
_coerce_message,
_coerce_message_content,
_coerce_response_for_request,
_coerce_responses_for_pending_requests,
_custom_event_value,
_details_code,
_details_message,
_interrupt_entry_for_request_event,
_latest_assistant_contents,
_latest_user_text,
_message_role_value,
_pending_request_events,
_request_payload_from_request_event,
_single_pending_response_from_value,
_text_from_contents,
_workflow_interrupt_event_value,
_workflow_payload_to_contents,
run_workflow_stream,
)
@@ -677,3 +696,734 @@ async def test_workflow_run_emits_run_error_when_stream_raises() -> None:
assert "RUN_ERROR" in event_types
run_error = next(event for event in events if event.type == "RUN_ERROR")
assert "workflow stream exploded" in run_error.message
# ── Helper function unit tests ──
class TestPendingRequestEvents:
"""Tests for _pending_request_events helper."""
async def test_no_runner_context(self):
"""Workflow without _runner_context returns empty dict."""
workflow = SimpleNamespace()
result = await _pending_request_events(cast(Any, workflow))
assert result == {}
async def test_runner_context_missing_get_pending(self):
"""Runner context without get_pending_request_info_events returns empty."""
workflow = SimpleNamespace(_runner_context=SimpleNamespace())
result = await _pending_request_events(cast(Any, workflow))
assert result == {}
async def test_get_pending_returns_non_dict(self):
"""get_pending returning non-dict returns empty dict."""
async def get_pending():
return ["not", "a", "dict"]
workflow = SimpleNamespace(_runner_context=SimpleNamespace(get_pending_request_info_events=get_pending))
result = await _pending_request_events(cast(Any, workflow))
assert result == {}
class TestInterruptEntryForRequestEvent:
"""Tests for _interrupt_entry_for_request_event helper."""
def test_request_id_none(self):
"""request_id=None returns None."""
event = SimpleNamespace(request_id=None)
assert _interrupt_entry_for_request_event(event) is None
def test_dict_data_used_directly(self):
"""Dict data is used as interrupt value."""
event = SimpleNamespace(request_id="r1", data={"key": "val"})
result = _interrupt_entry_for_request_event(event)
assert result == {"id": "r1", "value": {"key": "val"}}
def test_non_dict_data_wrapped(self):
"""Non-dict data is wrapped in {data: ...}."""
event = SimpleNamespace(request_id="r1", data="text")
result = _interrupt_entry_for_request_event(event)
assert result == {"id": "r1", "value": {"data": "text"}}
class TestRequestPayloadFromRequestEvent:
"""Tests for _request_payload_from_request_event helper."""
def test_falsy_request_id_returns_none(self):
"""Empty string request_id returns None."""
event = SimpleNamespace(request_id="", request_type=None, response_type=None, data=None)
assert _request_payload_from_request_event(event) is None
class TestCoerceJsonValue:
"""Tests for _coerce_json_value helper."""
def test_empty_string(self):
"""Empty string returns original value."""
assert _coerce_json_value("") == ""
def test_whitespace_string(self):
"""Whitespace-only string returns original value."""
assert _coerce_json_value(" ") == " "
def test_valid_json_parsed(self):
"""Valid JSON string is parsed."""
assert _coerce_json_value('{"a": 1}') == {"a": 1}
def test_invalid_json_returned_as_is(self):
"""Invalid JSON string returned as-is."""
assert _coerce_json_value("not json") == "not json"
def test_non_string_returned_as_is(self):
"""Non-string values returned as-is."""
assert _coerce_json_value(42) == 42
assert _coerce_json_value(None) is None
class TestCoerceContent:
"""Tests for _coerce_content helper."""
def test_already_content(self):
"""Content object returned as-is."""
content = Content.from_text(text="hello")
assert _coerce_content(content) is content
def test_non_dict_returns_none(self):
"""Non-dict value (after JSON parse) returns None."""
assert _coerce_content([1, 2, 3]) is None
assert _coerce_content(42) is None
def test_auto_function_approval_response_type_attempted(self):
"""Dict with approved+id+function_call triggers the auto-type detection path."""
# The function injects type="function_approval_response" into a copy,
# but Content.from_dict may fail for complex nested types - returns None.
value = {
"approved": True,
"id": "a1",
"function_call": {"call_id": "c1", "name": "fn", "arguments": "{}"},
}
# Exercises the auto-detection code path even though result is None
result = _coerce_content(value)
assert result is None # from_dict fails for this shape
def test_valid_text_content_dict(self):
"""Dict with type=text converts successfully."""
result = _coerce_content({"type": "text", "text": "hello"})
assert result is not None
assert result.type == "text"
assert result.text == "hello"
class TestCoerceMessageContent:
"""Tests for _coerce_message_content helper."""
def test_string_content(self):
"""String content creates text Content."""
result = _coerce_message_content("hello")
assert result is not None
assert result.type == "text"
assert result.text == "hello"
def test_already_content_object(self):
"""Content object returned as-is."""
content = Content.from_text(text="test")
assert _coerce_message_content(content) is content
def test_none_input_returns_none(self):
"""None input returns None."""
assert _coerce_message_content(None) is None
class TestCoerceMessage:
"""Tests for _coerce_message helper."""
def test_already_message(self):
"""Message object returned as-is."""
msg = Message(role="user", contents=[Content.from_text(text="hi")])
assert _coerce_message(msg) is msg
def test_non_dict_non_str_returns_none(self):
"""Non-dict/str (e.g. int) returns None."""
assert _coerce_message(123) is None
def test_empty_contents(self):
"""Dict with no contents key gets empty text content."""
msg = _coerce_message({"role": "user"})
assert msg is not None
assert len(msg.contents) == 1
assert msg.contents[0].text == ""
def test_dict_with_content_key_variant(self):
"""'content' key maps to contents."""
msg = _coerce_message({"role": "assistant", "content": "Done"})
assert msg is not None
assert msg.role == "assistant"
assert len(msg.contents) == 1
class TestCoerceResponseForRequest:
"""Tests for _coerce_response_for_request helper."""
def test_response_type_none(self):
"""None response_type returns candidate as-is."""
event = SimpleNamespace(response_type=None)
assert _coerce_response_for_request(event, "hello") == "hello"
def test_response_type_any(self):
"""Any response_type returns candidate as-is."""
event = SimpleNamespace(response_type=Any)
assert _coerce_response_for_request(event, {"a": 1}) == {"a": 1}
def test_list_coercion_bare_list(self):
"""list without type args passes through."""
event = SimpleNamespace(response_type=list)
assert _coerce_response_for_request(event, [1, 2]) == [1, 2]
def test_list_content_coercion(self):
"""list[Content] coerces dicts to Content objects."""
event = SimpleNamespace(response_type=list[Content])
result = _coerce_response_for_request(event, [{"type": "text", "text": "hi"}])
assert result is not None
assert len(result) == 1
assert isinstance(result[0], Content)
def test_list_message_coercion(self):
"""list[Message] coerces dicts to Message objects."""
event = SimpleNamespace(response_type=list[Message])
result = _coerce_response_for_request(event, [{"role": "user", "contents": [{"type": "text", "text": "hi"}]}])
assert result is not None
assert len(result) == 1
assert isinstance(result[0], Message)
def test_list_coercion_fails_returns_none(self):
"""list coercion returns None when items can't be converted."""
event = SimpleNamespace(response_type=list[Content])
result = _coerce_response_for_request(event, [None])
assert result is None
def test_str_coercion_from_dict(self):
"""str type coerces dict to JSON string."""
event = SimpleNamespace(response_type=str)
result = _coerce_response_for_request(event, {"a": 1})
assert isinstance(result, str)
assert '"a"' in result
def test_unknown_type_mismatch(self):
"""Custom class type returns None for non-instance."""
class Custom:
pass
event = SimpleNamespace(response_type=Custom)
assert _coerce_response_for_request(event, "not_custom") is None
def test_unknown_type_match(self):
"""Custom class type returns object if isinstance matches."""
class Custom:
pass
obj = Custom()
event = SimpleNamespace(response_type=Custom)
assert _coerce_response_for_request(event, obj) is obj
class TestSinglePendingResponseFromValue:
"""Tests for _single_pending_response_from_value helper."""
def test_missing_request_id(self):
"""Event with no request_id returns empty dict."""
event = SimpleNamespace(response_type=str)
pending = {"key": event}
result = _single_pending_response_from_value(pending, "value")
assert result == {}
def test_multiple_pending_returns_empty(self):
"""Multiple pending events returns empty dict (ambiguous)."""
e1 = SimpleNamespace(request_id="r1", response_type=str)
e2 = SimpleNamespace(request_id="r2", response_type=str)
result = _single_pending_response_from_value({"r1": e1, "r2": e2}, "val")
assert result == {}
class TestCoerceResponsesForPendingRequests:
"""Tests for _coerce_responses_for_pending_requests helper."""
def test_failed_coercion_skipped(self):
"""Incompatible type causes response to be skipped."""
event = SimpleNamespace(response_type=bool)
responses = {"r1": "not_a_bool"}
pending = {"r1": event}
result = _coerce_responses_for_pending_requests(responses, pending)
assert "r1" not in result
def test_unknown_request_id_preserved(self):
"""Responses for unknown request IDs are preserved as-is."""
responses = {"unknown_id": "value"}
pending = {}
result = _coerce_responses_for_pending_requests(responses, pending)
assert result == {"unknown_id": "value"}
def test_empty_responses(self):
"""Empty responses dict returns responses unchanged."""
result = _coerce_responses_for_pending_requests({}, {"r1": SimpleNamespace()})
assert result == {}
class TestMessageRoleValue:
"""Tests for _message_role_value helper."""
def test_string_role(self):
"""String role returned directly."""
msg = Message(role="user", contents=[])
assert _message_role_value(msg) == "user"
def test_enum_role(self):
"""Enum-like role gets .value."""
class Role(Enum):
USER = "user"
msg = SimpleNamespace(role=Role.USER)
assert _message_role_value(cast(Any, msg)) == "user"
class TestLatestUserText:
"""Tests for _latest_user_text helper."""
def test_only_assistant_messages(self):
"""Only assistant messages returns None."""
messages = [Message(role="assistant", contents=[Content.from_text(text="hi")])]
assert _latest_user_text(messages) is None
def test_user_with_non_text_content(self):
"""User message with only non-text content returns None."""
messages = [
Message(role="user", contents=[Content.from_function_call(call_id="c1", name="fn", arguments="{}")])
]
assert _latest_user_text(messages) is None
def test_user_with_empty_text(self):
"""User message with empty/whitespace text returns None."""
messages = [Message(role="user", contents=[Content.from_text(text=" ")])]
assert _latest_user_text(messages) is None
class TestLatestAssistantContents:
"""Tests for _latest_assistant_contents helper."""
def test_no_assistant_messages(self):
"""Only user messages returns None."""
messages = [Message(role="user", contents=[Content.from_text(text="hi")])]
assert _latest_assistant_contents(messages) is None
def test_assistant_with_empty_contents(self):
"""Assistant message with empty contents returns None."""
messages = [Message(role="assistant", contents=[])]
assert _latest_assistant_contents(messages) is None
class TestTextFromContents:
"""Tests for _text_from_contents helper."""
def test_empty_text_skipped(self):
"""Empty string text content is skipped."""
contents = [Content.from_text(text="")]
assert _text_from_contents(contents) is None
def test_non_text_content_skipped(self):
"""Non-text content types are skipped."""
contents = [Content.from_function_call(call_id="c1", name="fn", arguments="{}")]
assert _text_from_contents(contents) is None
class TestWorkflowInterruptEventValue:
"""Tests for _workflow_interrupt_event_value helper."""
def test_none_data(self):
"""None data returns None."""
assert _workflow_interrupt_event_value({"data": None}) is None
def test_string_data(self):
"""String data returned directly."""
assert _workflow_interrupt_event_value({"data": "text"}) == "text"
def test_dict_data_serialized(self):
"""Dict data is JSON-serialized."""
result = _workflow_interrupt_event_value({"data": {"key": "val"}})
assert json.loads(result) == {"key": "val"}
class TestWorkflowPayloadToContents:
"""Tests for _workflow_payload_to_contents helper."""
def test_none_payload(self):
"""None payload returns None."""
assert _workflow_payload_to_contents(None) is None
def test_non_assistant_message(self):
"""User Message returns None."""
msg = Message(role="user", contents=[Content.from_text(text="hi")])
assert _workflow_payload_to_contents(msg) is None
def test_agent_response_update_non_assistant(self):
"""AgentResponseUpdate with user role returns None."""
update = AgentResponseUpdate(contents=[Content.from_text(text="hi")], role="user")
assert _workflow_payload_to_contents(update) is None
def test_agent_response_update_none_role(self):
"""AgentResponseUpdate with None role returns None."""
update = AgentResponseUpdate(contents=[Content.from_text(text="hi")], role=None)
assert _workflow_payload_to_contents(update) is None
def test_list_with_none_item(self):
"""List containing None causes None return."""
result = _workflow_payload_to_contents([Content.from_text(text="hi"), None])
assert result is None
def test_empty_list(self):
"""Empty list returns None."""
assert _workflow_payload_to_contents([]) is None
def test_string_payload(self):
"""String payload creates text content."""
result = _workflow_payload_to_contents("hello")
assert result is not None
assert len(result) == 1
assert result[0].type == "text"
def test_content_payload(self):
"""Single Content returned as list."""
content = Content.from_text(text="test")
result = _workflow_payload_to_contents(content)
assert result == [content]
def test_unknown_type_returns_none(self):
"""Unknown types return None."""
assert _workflow_payload_to_contents(42) is None
class TestCustomEventValue:
"""Tests for _custom_event_value helper."""
def test_event_with_data(self):
"""Event with .data attribute returns data."""
event = SimpleNamespace(type="custom", data={"progress": 50})
assert _custom_event_value(event) == {"progress": 50}
def test_event_without_data(self):
"""Event without .data returns filtered custom fields."""
event = SimpleNamespace(type="custom", data=None, custom_field="value")
result = _custom_event_value(event)
assert result == {"custom_field": "value"}
def test_event_with_no_custom_fields(self):
"""Event with only base fields returns None."""
event = SimpleNamespace(type="custom", data=None)
result = _custom_event_value(event)
assert result is None
class TestDetailsMessage:
"""Tests for _details_message helper."""
def test_none_details(self):
"""None details returns default message."""
assert _details_message(None) == "Workflow execution failed."
def test_details_with_message(self):
"""Details with .message attribute uses it."""
details = SimpleNamespace(message="Custom error")
assert _details_message(details) == "Custom error"
def test_details_with_empty_message(self):
"""Details with empty .message falls back to str()."""
details = SimpleNamespace(message="")
result = _details_message(details)
assert "message=" in result or result == str(details)
def test_details_without_message(self):
"""Details without .message uses str()."""
assert _details_message("plain string") == "plain string"
class TestDetailsCode:
"""Tests for _details_code helper."""
def test_none_details(self):
"""None details returns None."""
assert _details_code(None) is None
def test_details_with_error_type(self):
"""Details with .error_type returns it."""
details = SimpleNamespace(error_type="ValueError")
assert _details_code(details) == "ValueError"
def test_details_with_empty_error_type(self):
"""Details with empty .error_type returns None."""
details = SimpleNamespace(error_type="")
assert _details_code(details) is None
def test_details_without_error_type(self):
"""Details without .error_type returns None."""
details = SimpleNamespace(message="err")
assert _details_code(details) is None
# ── Stream integration tests ──
async def test_workflow_run_available_interrupts_logged():
"""available_interrupts in input data should be logged without errors."""
@executor(id="noop")
async def noop(message: Any, ctx: WorkflowContext) -> None:
pass
workflow = WorkflowBuilder(start_executor=noop).build()
input_data = {
"messages": [{"role": "user", "content": "go"}],
"available_interrupts": [{"id": "req_1", "type": "request_info"}],
}
events = [event async for event in run_workflow_stream(input_data, workflow)]
event_types = [event.type for event in events]
assert "RUN_STARTED" in event_types
assert "RUN_FINISHED" in event_types
assert "RUN_ERROR" not in event_types
async def test_workflow_run_failed_event():
"""Workflow 'failed' event should produce RUN_ERROR."""
class FailingWorkflow:
def run(self, **kwargs: Any):
async def _stream():
yield SimpleNamespace(type="started")
yield SimpleNamespace(
type="failed", details=SimpleNamespace(message="it broke", error_type="TestError")
)
return _stream()
events = [
event
async for event in run_workflow_stream(
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, FailingWorkflow())
)
]
event_types = [event.type for event in events]
assert "RUN_STARTED" in event_types
assert "RUN_ERROR" in event_types
error_event = next(e for e in events if e.type == "RUN_ERROR")
assert error_event.message == "it broke"
assert error_event.code == "TestError"
async def test_workflow_run_status_enum_state():
"""Status events with enum-like state should be handled."""
class WorkflowState(Enum):
IDLE = "idle"
class StatusWorkflow:
def run(self, **kwargs: Any):
async def _stream():
yield SimpleNamespace(type="started")
yield SimpleNamespace(type="status", state=WorkflowState.IDLE)
return _stream()
events = [
event
async for event in run_workflow_stream(
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, StatusWorkflow())
)
]
event_types = [event.type for event in events]
assert "RUN_STARTED" in event_types
assert "RUN_FINISHED" in event_types
async def test_workflow_run_executor_invoked_drains_text():
"""executor_invoked should drain any open text message."""
class ExecutorWorkflow:
def run(self, **kwargs: Any):
async def _stream():
yield SimpleNamespace(type="started")
yield SimpleNamespace(type="output", data="Hello world")
yield SimpleNamespace(type="executor_invoked", executor_id="agent_1", data=None)
yield SimpleNamespace(type="executor_completed", executor_id="agent_1", data=None)
return _stream()
events = [
event
async for event in run_workflow_stream(
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, ExecutorWorkflow())
)
]
# Text should end before executor step starts
text_end_idx = next(i for i, e in enumerate(events) if e.type == "TEXT_MESSAGE_END")
step_start_idx = next(i for i, e in enumerate(events) if e.type == "STEP_STARTED")
assert text_end_idx < step_start_idx
async def test_workflow_run_executor_failed_event():
"""executor_failed event should emit activity snapshot with failed status."""
class ExecutorFailWorkflow:
def run(self, **kwargs: Any):
async def _stream():
yield SimpleNamespace(type="started")
yield SimpleNamespace(
type="executor_failed",
executor_id="agent_1",
details=SimpleNamespace(message="agent crashed"),
)
return _stream()
events = [
event
async for event in run_workflow_stream(
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, ExecutorFailWorkflow())
)
]
activity = [e for e in events if e.type == "ACTIVITY_SNAPSHOT"]
assert len(activity) == 1
assert activity[0].content["status"] == "failed"
assert activity[0].content["details"]["message"] == "agent crashed"
async def test_workflow_run_list_base_event_output():
"""Workflow yielding list of BaseEvent objects should emit each."""
class ListEventWorkflow:
def run(self, **kwargs: Any):
async def _stream():
yield SimpleNamespace(type="started")
yield SimpleNamespace(
type="output",
data=[
StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot={"a": 1}),
StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot={"b": 2}),
],
)
return _stream()
events = [
event
async for event in run_workflow_stream(
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, ListEventWorkflow())
)
]
snapshots = [e for e in events if e.type == "STATE_SNAPSHOT"]
assert len(snapshots) == 2
assert snapshots[0].snapshot == {"a": 1}
assert snapshots[1].snapshot == {"b": 2}
async def test_workflow_run_late_run_started():
"""If no events emitted, RUN_STARTED still emitted at end."""
class EmptyWorkflow:
def run(self, **kwargs: Any):
async def _stream():
return
yield # pragma: no cover
return _stream()
events = [
event
async for event in run_workflow_stream(
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, EmptyWorkflow())
)
]
assert events[0].type == "RUN_STARTED"
assert events[-1].type == "RUN_FINISHED"
async def test_workflow_run_last_assistant_text_update():
"""Text outputs update last_assistant_text for dedup tracking."""
class DualTextWorkflow:
def run(self, **kwargs: Any):
async def _stream():
yield SimpleNamespace(type="started")
yield SimpleNamespace(type="output", data="First text")
yield SimpleNamespace(type="output", data="Second text")
return _stream()
events = [
event
async for event in run_workflow_stream(
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, DualTextWorkflow())
)
]
text_deltas = [e.delta for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
assert "First text" in text_deltas
assert "Second text" in text_deltas
async def test_workflow_run_superstep_events():
"""superstep_started/completed emit Step events with iteration."""
class SuperstepWorkflow:
def run(self, **kwargs: Any):
async def _stream():
yield SimpleNamespace(type="started")
yield SimpleNamespace(type="superstep_started", iteration=1)
yield SimpleNamespace(type="superstep_completed", iteration=1)
return _stream()
events = [
event
async for event in run_workflow_stream(
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, SuperstepWorkflow())
)
]
step_started = [e for e in events if e.type == "STEP_STARTED"]
step_finished = [e for e in events if e.type == "STEP_FINISHED"]
assert len(step_started) == 1
assert step_started[0].step_name == "superstep:1"
assert len(step_finished) == 1
assert step_finished[0].step_name == "superstep:1"
async def test_workflow_run_non_terminal_status_emits_custom():
"""Non-terminal status events emit custom events."""
class StatusWorkflow:
def run(self, **kwargs: Any):
async def _stream():
yield SimpleNamespace(type="started")
yield SimpleNamespace(type="status", state="running")
return _stream()
events = [
event
async for event in run_workflow_stream(
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, StatusWorkflow())
)
]
custom = [e for e in events if e.type == "CUSTOM" and e.name == "status"]
assert len(custom) == 1
assert custom[0].value == {"state": "running"}
@@ -1461,8 +1461,9 @@ class AzureAIAgentClient(
Keyword Args:
id: The unique identifier for the agent. Will be created automatically if not provided.
name: The name of the agent.
description: A brief description of the agent's purpose.
name: The name of the agent. Defaults to the client's ``agent_name`` when None.
description: A brief description of the agent's purpose. Defaults to the client's
``agent_description`` when None.
instructions: Optional instructions for the agent.
tools: The tools to use for the request.
default_options: A TypedDict containing chat options.
@@ -1475,8 +1476,8 @@ class AzureAIAgentClient(
"""
return super().as_agent(
id=id,
name=name,
description=description,
name=self.agent_name if name is None else name,
description=self.agent_description if description is None else description,
instructions=instructions,
tools=tools,
default_options=default_options,
@@ -1189,8 +1189,9 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
Keyword Args:
id: The unique identifier for the agent. Will be created automatically if not provided.
name: The name of the agent.
description: A brief description of the agent's purpose.
name: The name of the agent. Defaults to the client's ``agent_name`` when None.
description: A brief description of the agent's purpose. Defaults to the client's
``agent_description`` when None.
instructions: Optional instructions for the agent.
tools: The tools to use for the request.
default_options: A TypedDict containing chat options.
@@ -1203,8 +1204,8 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
"""
return super().as_agent(
id=id,
name=name,
description=description,
name=self.agent_name if name is None else name,
description=self.agent_description if description is None else description,
instructions=instructions,
tools=tools,
default_options=default_options,
@@ -509,6 +509,48 @@ async def test_azure_ai_chat_client_prepare_options_merges_instructions_from_mes
assert "concise" in instructions_text.lower()
def test_as_agent_uses_client_agent_name_as_default(mock_agents_client: MagicMock) -> None:
"""Test that as_agent() defaults Agent.name to client.agent_name when name is not provided."""
client = create_test_azure_ai_chat_client(mock_agents_client, agent_name="my_agent")
client.agent_description = "my description"
agent = client.as_agent(instructions="You are helpful.")
assert agent.name == "my_agent"
assert agent.description == "my description"
def test_as_agent_explicit_name_overrides_client_agent_name(mock_agents_client: MagicMock) -> None:
"""Test that an explicit name passed to as_agent() takes precedence over client.agent_name."""
client = create_test_azure_ai_chat_client(mock_agents_client, agent_name="client_name")
client.agent_description = "client description"
agent = client.as_agent(name="explicit_name", description="explicit description", instructions="You are helpful.")
assert agent.name == "explicit_name"
assert agent.description == "explicit description"
def test_as_agent_no_name_anywhere(mock_agents_client: MagicMock) -> None:
"""Test that Agent.name is None when neither as_agent name nor client.agent_name is provided."""
client = create_test_azure_ai_chat_client(mock_agents_client)
agent = client.as_agent(instructions="You are helpful.")
assert agent.name is None
def test_as_agent_empty_string_preserves_explicit_value(mock_agents_client: MagicMock) -> None:
"""Test that empty-string name/description are preserved and do not fall back to client defaults."""
client = create_test_azure_ai_chat_client(mock_agents_client, agent_name="client_name")
client.agent_description = "client description"
agent = client.as_agent(name="", description="", instructions="You are helpful.")
assert agent.name == ""
assert agent.description == ""
async def test_azure_ai_chat_client_inner_get_response(mock_agents_client: MagicMock) -> None:
"""Test _inner_get_response method."""
client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
@@ -546,6 +546,48 @@ def test_update_agent_name_and_description(mock_project_client: MagicMock) -> No
mock_update.assert_called_once_with(None)
def test_as_agent_uses_client_agent_name_as_default(mock_project_client: MagicMock) -> None:
"""Test that as_agent() defaults Agent.name to client.agent_name when name is not provided."""
client = create_test_azure_ai_client(mock_project_client, agent_name="my_agent")
client.agent_description = "my description"
agent = client.as_agent(instructions="You are helpful.")
assert agent.name == "my_agent"
assert agent.description == "my description"
def test_as_agent_explicit_name_overrides_client_agent_name(mock_project_client: MagicMock) -> None:
"""Test that an explicit name passed to as_agent() takes precedence over client.agent_name."""
client = create_test_azure_ai_client(mock_project_client, agent_name="client_name")
client.agent_description = "client description"
agent = client.as_agent(name="explicit_name", description="explicit description", instructions="You are helpful.")
assert agent.name == "explicit_name"
assert agent.description == "explicit description"
def test_as_agent_no_name_anywhere(mock_project_client: MagicMock) -> None:
"""Test that Agent.name is None when neither as_agent name nor client.agent_name is provided."""
client = create_test_azure_ai_client(mock_project_client)
agent = client.as_agent(instructions="You are helpful.")
assert agent.name is None
def test_as_agent_empty_string_preserves_explicit_value(mock_project_client: MagicMock) -> None:
"""Test that empty-string name/description are preserved and do not fall back to client defaults."""
client = create_test_azure_ai_client(mock_project_client, agent_name="client_name")
client.agent_description = "client description"
agent = client.as_agent(name="", description="", instructions="You are helpful.")
assert agent.name == ""
assert agent.description == ""
async def test_async_context_manager(mock_project_client: MagicMock) -> None:
"""Test async context manager functionality."""
client = create_test_azure_ai_client(mock_project_client, should_close_client=True)
@@ -901,7 +901,11 @@ class MCPTool:
for attempt in range(2):
try:
result = await self.session.call_tool(tool_name, arguments=filtered_kwargs, meta=otel_meta) # type: ignore
if result.isError:
raise ToolExecutionException(parser(result))
return parser(result)
except ToolExecutionException:
raise
except ClosedResourceError as cl_ex:
if attempt == 0:
# First attempt failed, try reconnecting
@@ -563,10 +563,10 @@ class SkillsProvider(BaseContextProvider):
try:
if inspect.iscoroutinefunction(resource.function):
result = (
await resource.function(**kwargs) if resource._accepts_kwargs else await resource.function()
await resource.function(**kwargs) if resource._accepts_kwargs else await resource.function() # pyright: ignore[reportPrivateUsage]
)
else:
result = resource.function(**kwargs) if resource._accepts_kwargs else resource.function()
result = resource.function(**kwargs) if resource._accepts_kwargs else resource.function() # pyright: ignore[reportPrivateUsage]
return str(result)
except Exception as exc:
logger.exception("Failed to read resource '%s' from skill '%s'", resource_name, skill_name)
+146 -2
View File
@@ -14,6 +14,8 @@ from pydantic import AnyUrl, BaseModel
from agent_framework import (
Content,
FunctionInvocationContext,
FunctionMiddleware,
MCPStdioTool,
MCPStreamableHTTPTool,
MCPWebsocketTool,
@@ -30,6 +32,7 @@ from agent_framework._mcp import (
_prepare_message_for_mcp,
logger,
)
from agent_framework._middleware import FunctionMiddlewarePipeline
from agent_framework.exceptions import ToolException, ToolExecutionException
# Integration test skip condition
@@ -898,6 +901,147 @@ async def test_local_mcp_server_function_execution_error():
await func.invoke(param="test_value")
async def test_mcp_tool_call_tool_raises_on_is_error():
"""Test that call_tool raises ToolExecutionException when MCP returns isError=True."""
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description="Test tool",
inputSchema={
"type": "object",
"properties": {"param": {"type": "string"}},
"required": ["param"],
},
)
]
)
)
self.session.call_tool = AsyncMock(
return_value=types.CallToolResult(
content=[types.TextContent(type="text", text="Something went wrong")],
isError=True,
)
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
await server.load_tools()
func = server.functions[0]
with pytest.raises(ToolExecutionException, match="Something went wrong"):
await func.invoke(param="test_value")
async def test_mcp_tool_call_tool_succeeds_when_is_error_false():
"""Test that call_tool returns normally when MCP returns isError=False."""
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description="Test tool",
inputSchema={
"type": "object",
"properties": {"param": {"type": "string"}},
"required": ["param"],
},
)
]
)
)
self.session.call_tool = AsyncMock(
return_value=types.CallToolResult(
content=[types.TextContent(type="text", text="Success")],
isError=False,
)
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
await server.load_tools()
func = server.functions[0]
result = await func.invoke(param="test_value")
assert result == "Success"
async def test_mcp_tool_is_error_propagates_through_function_middleware():
"""Test that MCP isError=True propagates as ToolExecutionException through function middleware."""
error_seen_in_middleware = False
class ErrorCheckMiddleware(FunctionMiddleware):
async def process(self, context: FunctionInvocationContext, call_next):
nonlocal error_seen_in_middleware
try:
await call_next()
except ToolExecutionException:
error_seen_in_middleware = True
raise
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description="Test tool",
inputSchema={
"type": "object",
"properties": {"param": {"type": "string"}},
"required": ["param"],
},
)
]
)
)
self.session.call_tool = AsyncMock(
return_value=types.CallToolResult(
content=[types.TextContent(type="text", text="MCP error occurred")],
isError=True,
)
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
await server.load_tools()
func = server.functions[0]
middleware_pipeline = FunctionMiddlewarePipeline(ErrorCheckMiddleware())
middleware_context = FunctionInvocationContext(
function=func,
arguments={"param": "test_value"},
)
with pytest.raises(ToolExecutionException, match="MCP error occurred"):
await middleware_pipeline.execute(
middleware_context,
lambda ctx: func.invoke(arguments=ctx.arguments),
)
assert error_seen_in_middleware, "Middleware should have seen the ToolExecutionException"
async def test_local_mcp_server_prompt_execution():
"""Test prompt execution through MCP server."""
@@ -2098,7 +2242,7 @@ async def test_mcp_tool_connection_properly_invalidated_after_closed_resource_er
tool._tools_loaded = True
# First call should work - connection is valid
mock_session.call_tool.return_value = MagicMock(content=[])
mock_session.call_tool.return_value = types.CallToolResult(content=[])
result = await tool.call_tool("test_tool", arg1="value1")
assert result is not None
@@ -2111,7 +2255,7 @@ async def test_mcp_tool_connection_properly_invalidated_after_closed_resource_er
call_count += 1
if call_count == 1:
raise ClosedResourceError
return MagicMock(content=[])
return types.CallToolResult(content=[])
mock_session.call_tool = call_tool_with_error
@@ -22,7 +22,7 @@ from agent_framework.exceptions import (
IntegrationInvalidRequestException,
)
from redisvl.index import AsyncSearchIndex
from redisvl.query import HybridQuery, TextQuery
from redisvl.query import AggregateHybridQuery, TextQuery
from redisvl.query.filter import FilterExpression, Tag
from redisvl.utils.token_escaper import TokenEscaper
from redisvl.utils.vectorize import BaseVectorizer
@@ -341,7 +341,7 @@ class RedisContextProvider(BaseContextProvider):
filter_expression: Any | None = None,
return_fields: list[str] | None = None,
num_results: int = 10,
linear_alpha: float = 0.7,
alpha: float = 0.7,
) -> list[dict[str, Any]]:
"""Runs a text or hybrid vector-text search with optional filters."""
await self._ensure_index()
@@ -371,14 +371,14 @@ class RedisContextProvider(BaseContextProvider):
try:
if self.redis_vectorizer and self.vector_field_name:
vector = await self.redis_vectorizer.aembed(q) # pyright: ignore[reportUnknownMemberType]
query = HybridQuery(
query = AggregateHybridQuery(
text=q,
text_field_name="content",
vector=vector,
vector_field_name=self.vector_field_name,
text_scorer=text_scorer,
filter_expression=combined_filter,
linear_alpha=linear_alpha,
alpha=alpha,
dtype=self.redis_vectorizer.dtype, # pyright: ignore[reportUnknownMemberType]
num_results=num_results,
return_fields=return_fields,
@@ -271,6 +271,44 @@ class TestRedisContextProviderContextManager:
assert p is provider
class TestRedisContextProviderHybridQuery:
"""Test for AggregateHybridQuery parameter compatibility with redisvl 0.14.0."""
async def test_aggregate_hybrid_query_uses_alpha(
self,
mock_index: AsyncMock,
patch_index_from_dict: MagicMock, # noqa: ARG002 - fixture modifies behavior via side effects
):
"""Ensure AggregateHybridQuery is called with alpha parameter."""
from redisvl.utils.vectorize import BaseVectorizer
# Create a mock vectorizer that inherits from BaseVectorizer
mock_vectorizer = MagicMock(spec=BaseVectorizer)
mock_vectorizer.dims = 128
mock_vectorizer.dtype = "float32"
mock_vectorizer.aembed = AsyncMock(return_value=[0.1] * 128)
mock_index.query = AsyncMock(return_value=[{"content": "test result"}])
provider = RedisContextProvider(
source_id="ctx",
user_id="u1",
redis_vectorizer=mock_vectorizer,
vector_field_name="embedding",
)
# Call _redis_search with custom alpha
with patch("agent_framework_redis._context_provider.AggregateHybridQuery") as mock_hybrid_query:
mock_hybrid_query.return_value = MagicMock()
await provider._redis_search(text="test query", alpha=0.5)
# Verify AggregateHybridQuery was called with alpha parameter
mock_hybrid_query.assert_called_once()
call_kwargs = mock_hybrid_query.call_args.kwargs
assert "alpha" in call_kwargs
assert call_kwargs["alpha"] == 0.5
# ===========================================================================
# RedisHistoryProvider tests
# ===========================================================================