test
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
Python - Merge - Tests / paths-filter (push) Has been cancelled
Python - Merge - Tests / Python Tests - Core (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / Python Tests - Azure AI (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / python-integration-tests-check (push) Has been cancelled
Python - Lab Tests / paths-filter (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.14) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.14) (push) Has been cancelled
Check .md links / markdown-link-check (push) Has been cancelled

This commit is contained in:
2026-01-24 03:05:12 +11:00
parent f78f2388b3
commit 539852f81c
2584 changed files with 287471 additions and 0 deletions

View File

@@ -0,0 +1,198 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Reflection;
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.Entities;
using Microsoft.DurableTask.Entities;
using Microsoft.Extensions.Configuration;
using OpenAI.Chat;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
/// <summary>
/// Tests for scenarios where an external client interacts with Durable Task Agents.
/// </summary>
[Collection("Sequential")]
[Trait("Category", "Integration")]
public sealed class AgentEntityTests(ITestOutputHelper outputHelper) : IDisposable
{
private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached
? TimeSpan.FromMinutes(5)
: TimeSpan.FromSeconds(30);
private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
private readonly ITestOutputHelper _outputHelper = outputHelper;
private readonly CancellationTokenSource _cts = new(delay: s_defaultTimeout);
private CancellationToken TestTimeoutToken => this._cts.Token;
public void Dispose() => this._cts.Dispose();
[Fact]
public async Task EntityNamePrefixAsync()
{
// Setup
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent(
name: "TestAgent",
instructions: "You are a helpful assistant that always responds with a friendly greeting."
);
using TestHelper testHelper = TestHelper.Start([simpleAgent], this._outputHelper);
// A proxy agent is needed to call the hosted test agent
AIAgent simpleAgentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
AgentThread thread = await simpleAgentProxy.GetNewThreadAsync(this.TestTimeoutToken);
DurableTaskClient client = testHelper.GetClient();
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
EntityInstanceId expectedEntityId = new($"dafx-{simpleAgent.Name}", sessionId.Key);
EntityMetadata? entity = await client.Entities.GetEntityAsync(expectedEntityId, false, this.TestTimeoutToken);
Assert.Null(entity);
// Act: send a prompt to the agent
await simpleAgentProxy.RunAsync(
message: "Hello!",
thread,
cancellationToken: this.TestTimeoutToken);
// Assert: verify the agent state was stored with the correct entity name prefix
entity = await client.Entities.GetEntityAsync(expectedEntityId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
DurableAgentState state = entity.State.ReadAs<DurableAgentState>();
DurableAgentStateRequest request = Assert.Single(state.Data.ConversationHistory.OfType<DurableAgentStateRequest>());
Assert.Null(request.OrchestrationId);
}
[Theory]
[InlineData("run")]
[InlineData("Run")]
[InlineData("RunAgentAsync")]
public async Task RunAgentMethodNamesAllWorkAsync(string runAgentMethodName)
{
// Setup
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent(
name: "TestAgent",
instructions: "You are a helpful assistant that always responds with a friendly greeting."
);
using TestHelper testHelper = TestHelper.Start([simpleAgent], this._outputHelper);
// A proxy agent is needed to call the hosted test agent
AIAgent simpleAgentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
AgentThread thread = await simpleAgentProxy.GetNewThreadAsync(this.TestTimeoutToken);
DurableTaskClient client = testHelper.GetClient();
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
EntityInstanceId expectedEntityId = new($"dafx-{simpleAgent.Name}", sessionId.Key);
EntityMetadata? entity = await client.Entities.GetEntityAsync(expectedEntityId, false, this.TestTimeoutToken);
Assert.Null(entity);
// Act: send a prompt to the agent
await client.Entities.SignalEntityAsync(
expectedEntityId,
runAgentMethodName,
new RunRequest("Hello!"),
cancellation: this.TestTimeoutToken);
while (!this.TestTimeoutToken.IsCancellationRequested)
{
await Task.Delay(500, this.TestTimeoutToken);
// Assert: verify the agent state was stored with the correct entity name prefix
entity = await client.Entities.GetEntityAsync(expectedEntityId, true, this.TestTimeoutToken);
if (entity is not null)
{
break;
}
}
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
DurableAgentState state = entity.State.ReadAs<DurableAgentState>();
DurableAgentStateRequest request = Assert.Single(state.Data.ConversationHistory.OfType<DurableAgentStateRequest>());
Assert.Null(request.OrchestrationId);
}
[Fact]
public async Task OrchestrationIdSetDuringOrchestrationAsync()
{
// Arrange
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent(
name: "TestAgent",
instructions: "You are a helpful assistant that always responds with a friendly greeting."
);
using TestHelper testHelper = TestHelper.Start(
[simpleAgent],
this._outputHelper,
registry => registry.AddOrchestrator<TestOrchestrator>());
DurableTaskClient client = testHelper.GetClient();
// Act
string orchestrationId = await client.ScheduleNewOrchestrationInstanceAsync(nameof(TestOrchestrator), "What is the capital of Maine?");
OrchestrationMetadata? status = await client.WaitForInstanceCompletionAsync(
orchestrationId,
true,
this.TestTimeoutToken);
// Assert
EntityInstanceId expectedEntityId = AgentSessionId.Parse(status.ReadOutputAs<string>()!);
EntityMetadata? entity = await client.Entities.GetEntityAsync(expectedEntityId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
DurableAgentState state = entity.State.ReadAs<DurableAgentState>();
DurableAgentStateRequest request = Assert.Single(state.Data.ConversationHistory.OfType<DurableAgentStateRequest>());
Assert.Equal(orchestrationId, request.OrchestrationId);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Constructed via reflection.")]
private sealed class TestOrchestrator : TaskOrchestrator<string, string>
{
public override async Task<string> RunAsync(TaskOrchestrationContext context, string input)
{
DurableAIAgent writer = context.GetAgent("TestAgent");
AgentThread writerThread = await writer.GetNewThreadAsync();
await writer.RunAsync(
message: context.GetInput<string>()!,
thread: writerThread);
AgentSessionId sessionId = writerThread.GetService<AgentSessionId>();
return sessionId.ToString();
}
}
}

View File

@@ -0,0 +1,960 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
[Collection("Samples")]
[Trait("Category", "SampleValidation")]
public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime
{
private const string DtsPort = "8080";
private const string RedisPort = "6379";
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
private static bool s_infrastructureStarted;
private static readonly string s_samplesPath = Path.GetFullPath(
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "DurableAgents", "ConsoleApps"));
private readonly ITestOutputHelper _outputHelper = outputHelper;
async Task IAsyncLifetime.InitializeAsync()
{
if (!s_infrastructureStarted)
{
await this.StartSharedInfrastructureAsync();
s_infrastructureStarted = true;
}
}
async Task IAsyncLifetime.DisposeAsync()
{
// Nothing to clean up
await Task.CompletedTask;
}
[Fact]
public async Task SingleAgentSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "01_SingleAgent");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
string agentResponse = string.Empty;
bool inputSent = false;
// Read output from logs queue
string? line;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
// Look for the agent's response. Unlike the interactive mode, we won't actually see a line
// that starts with "Joker: ". Instead, we'll see a line that looks like "You: Joker: ..." because
// the standard input is *not* echoed back to standard output.
if (line.Contains("Joker: ", StringComparison.OrdinalIgnoreCase))
{
// This will give us the first line of the agent's response, which is all we need to verify that the agent is working.
agentResponse = line.Substring("Joker: ".Length).Trim();
break;
}
else if (!inputSent)
{
// Send input to stdin after we've started seeing output from the app
await this.WriteInputAsync(process, "Tell me a joke about a pirate.", testTimeoutCts.Token);
inputSent = true;
}
}
Assert.True(inputSent, "Input was not sent to the agent");
Assert.NotEmpty(agentResponse);
// Send exit command
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
});
}
[Fact]
public async Task SingleAgentOrchestrationChainingSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "02_AgentOrchestration_Chaining");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
// Console app runs automatically, just wait for completion
string? line;
bool foundSuccess = false;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
if (line.Contains("Orchestration completed successfully!", StringComparison.OrdinalIgnoreCase))
{
foundSuccess = true;
}
if (line.Contains("Result:", StringComparison.OrdinalIgnoreCase))
{
string result = line.Substring("Result:".Length).Trim();
Assert.NotEmpty(result);
break;
}
// Check for failure
if (line.Contains("Orchestration failed!", StringComparison.OrdinalIgnoreCase))
{
Assert.Fail("Orchestration failed.");
}
}
Assert.True(foundSuccess, "Orchestration did not complete successfully.");
});
}
[Fact]
public async Task MultiAgentConcurrencySampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "03_AgentOrchestration_Concurrency");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
// Send input to stdin
await this.WriteInputAsync(process, "What is temperature?", testTimeoutCts.Token);
// Read output from logs queue
StringBuilder output = new();
string? line;
bool foundSuccess = false;
bool foundPhysicist = false;
bool foundChemist = false;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
output.AppendLine(line);
if (line.Contains("Orchestration completed successfully!", StringComparison.OrdinalIgnoreCase))
{
foundSuccess = true;
}
if (line.Contains("Physicist's response:", StringComparison.OrdinalIgnoreCase))
{
foundPhysicist = true;
}
if (line.Contains("Chemist's response:", StringComparison.OrdinalIgnoreCase))
{
foundChemist = true;
}
// Check for failure
if (line.Contains("Orchestration failed!", StringComparison.OrdinalIgnoreCase))
{
Assert.Fail("Orchestration failed.");
}
// Stop reading once we have both responses
if (foundSuccess && foundPhysicist && foundChemist)
{
break;
}
}
Assert.True(foundSuccess, "Orchestration did not complete successfully.");
Assert.True(foundPhysicist, "Physicist response not found.");
Assert.True(foundChemist, "Chemist response not found.");
});
}
[Fact]
public async Task MultiAgentConditionalSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "04_AgentOrchestration_Conditionals");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
// Test with legitimate email
await this.TestSpamDetectionAsync(
process: process,
logs: logs,
emailId: "email-001",
emailContent: "Hi John. I wanted to follow up on our meeting yesterday about the quarterly report. Could you please send me the updated figures by Friday? Thanks!",
expectedSpam: false,
testTimeoutCts.Token);
// Restart the process for the second test
await process.WaitForExitAsync();
});
// Run second test with spam email
using CancellationTokenSource testTimeoutCts2 = this.CreateTestTimeoutCts();
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
await this.TestSpamDetectionAsync(
process,
logs,
emailId: "email-002",
emailContent: "URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!",
expectedSpam: true,
testTimeoutCts2.Token);
});
}
private async Task TestSpamDetectionAsync(
Process process,
BlockingCollection<OutputLog> logs,
string emailId,
string emailContent,
bool expectedSpam,
CancellationToken cancellationToken)
{
// Send email content to stdin
await this.WriteInputAsync(process, emailContent, cancellationToken);
// Read output from logs queue
string? line;
bool foundSuccess = false;
while ((line = this.ReadLogLine(logs, cancellationToken)) != null)
{
if (line.Contains("Email sent", StringComparison.OrdinalIgnoreCase))
{
Assert.False(expectedSpam, "Email was sent, but was expected to be marked as spam.");
}
if (line.Contains("Email marked as spam", StringComparison.OrdinalIgnoreCase))
{
Assert.True(expectedSpam, "Email was marked as spam, but was expected to be sent.");
}
if (line.Contains("Orchestration completed successfully!", StringComparison.OrdinalIgnoreCase))
{
foundSuccess = true;
break;
}
// Check for failure
if (line.Contains("Orchestration failed!", StringComparison.OrdinalIgnoreCase))
{
Assert.Fail("Orchestration failed.");
}
}
Assert.True(foundSuccess, "Orchestration did not complete successfully.");
}
[Fact]
public async Task SingleAgentOrchestrationHITLSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
// Start the HITL orchestration following the happy path from README
await this.WriteInputAsync(process, "The Future of Artificial Intelligence", testTimeoutCts.Token);
await this.WriteInputAsync(process, "3", testTimeoutCts.Token);
await this.WriteInputAsync(process, "72", testTimeoutCts.Token);
// Read output from logs queue
string? line;
bool rejectionSent = false;
bool approvalSent = false;
bool contentPublished = false;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
// Look for notification that content is ready. The first time we see this, we should send a rejection.
// The second time we see this, we should send approval.
if (line.Contains("Content is ready for review", StringComparison.OrdinalIgnoreCase))
{
if (!rejectionSent)
{
// Prompt: Approve? (y/n):
await this.WriteInputAsync(process, "n", testTimeoutCts.Token);
// Prompt: Feedback (optional):
await this.WriteInputAsync(
process,
"The article needs more technical depth and better examples. Rewrite it with less than 300 words.",
testTimeoutCts.Token);
rejectionSent = true;
}
else if (!approvalSent)
{
// Prompt: Approve? (y/n):
await this.WriteInputAsync(process, "y", testTimeoutCts.Token);
// Prompt: Feedback (optional):
await this.WriteInputAsync(process, "Looks good!", testTimeoutCts.Token);
approvalSent = true;
}
else
{
// This should never happen
Assert.Fail("Unexpected message found.");
}
}
// Look for success message
if (line.Contains("PUBLISHING: Content has been published", StringComparison.OrdinalIgnoreCase))
{
contentPublished = true;
break;
}
// Check for failure
if (line.Contains("Orchestration failed", StringComparison.OrdinalIgnoreCase))
{
Assert.Fail("Orchestration failed.");
}
}
Assert.True(rejectionSent, "Wasn't prompted with the first draft.");
Assert.True(approvalSent, "Wasn't prompted with the second draft.");
Assert.True(contentPublished, "Content was not published.");
});
}
[Fact]
public async Task LongRunningToolsSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
// This test takes a bit longer to run due to the multiple agent interactions and the lengthy content generation.
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(90));
// Test starting an agent that schedules a content generation orchestration
await this.WriteInputAsync(
process,
"Start a content generation workflow for the topic 'The Future of Artificial Intelligence'. Keep it less than 300 words.",
testTimeoutCts.Token);
// Read output from logs queue
bool rejectionSent = false;
bool approvalSent = false;
bool contentPublished = false;
string? line;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
// Look for notification that content is ready. The first time we see this, we should send a rejection.
// The second time we see this, we should send approval.
if (line.Contains("NOTIFICATION: Please review the following content for approval", StringComparison.OrdinalIgnoreCase))
{
// Wait for the notification to be fully written to the console
await Task.Delay(TimeSpan.FromSeconds(1), testTimeoutCts.Token);
if (!rejectionSent)
{
// Reject the content with feedback. Note that we need to send a newline character to the console first before sending the input.
await this.WriteInputAsync(
process,
"\nReject the content with feedback: Make it even shorter.",
testTimeoutCts.Token);
rejectionSent = true;
}
else if (!approvalSent)
{
// Approve the content. Note that we need to send a newline character to the console first before sending the input.
await this.WriteInputAsync(
process,
"\nApprove the content",
testTimeoutCts.Token);
approvalSent = true;
}
else
{
// This should never happen
Assert.Fail("Unexpected message found.");
}
}
// Look for success message
if (line.Contains("PUBLISHING: Content has been published successfully", StringComparison.OrdinalIgnoreCase))
{
contentPublished = true;
// Ask for the status of the workflow to confirm that it completed successfully.
await Task.Delay(TimeSpan.FromSeconds(1), testTimeoutCts.Token);
await this.WriteInputAsync(process, "\nGet the status of the workflow you previously started", testTimeoutCts.Token);
}
// Check for workflow completion or failure
if (contentPublished)
{
if (line.Contains("Completed", StringComparison.OrdinalIgnoreCase))
{
break;
}
else if (line.Contains("Failed", StringComparison.OrdinalIgnoreCase))
{
Assert.Fail("Workflow failed.");
}
}
}
Assert.True(rejectionSent, "Wasn't prompted with the first draft.");
Assert.True(approvalSent, "Wasn't prompted with the second draft.");
Assert.True(contentPublished, "Content was not published.");
});
}
[Fact]
public async Task ReliableStreamingSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "07_ReliableStreaming");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
// This test takes a bit longer to run due to the multiple agent interactions and the lengthy content generation.
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(90));
// Test the agent endpoint with a simple prompt
await this.WriteInputAsync(process, "Plan a 5-day trip to Seattle. Include daily activities.", testTimeoutCts.Token);
// Read output from stdout - should stream in real-time
// NOTE: The sample uses Console.Write() for streaming chunks, which means content may not be line-buffered.
// We test the interrupt/resume flow by:
// 1. Waiting for at least 10 lines of content
// 2. Sending Enter to interrupt
// 3. Verifying we get "Last cursor" output
// 4. Sending Enter again to resume
// 5. Verifying we get more content and that we're not restarting from the beginning
string? line;
bool foundConversationStart = false;
int contentLinesBeforeInterrupt = 0;
int contentLinesAfterResume = 0;
bool foundLastCursor = false;
bool foundResumeMessage = false;
bool interrupted = false;
bool resumed = false;
// Read output with a reasonable timeout
using CancellationTokenSource readTimeoutCts = this.CreateTestTimeoutCts();
DateTime? interruptTime = null;
try
{
while ((line = this.ReadLogLine(logs, readTimeoutCts.Token)) != null)
{
// Look for the conversation start message (updated format)
if (line.Contains("Conversation ID", StringComparison.OrdinalIgnoreCase))
{
foundConversationStart = true;
continue;
}
// Check if this is a content line (not prompts or status messages)
bool isContentLine = !string.IsNullOrWhiteSpace(line) &&
!line.Contains("Conversation ID", StringComparison.OrdinalIgnoreCase) &&
!line.Contains("Press [Enter]", StringComparison.OrdinalIgnoreCase) &&
!line.Contains("You:", StringComparison.OrdinalIgnoreCase) &&
!line.Contains("exit", StringComparison.OrdinalIgnoreCase) &&
!line.Contains("Stream cancelled", StringComparison.OrdinalIgnoreCase) &&
!line.Contains("Resuming conversation", StringComparison.OrdinalIgnoreCase) &&
!line.Contains("Last cursor", StringComparison.OrdinalIgnoreCase);
// Phase 1: Collect content before interrupt
if (foundConversationStart && !interrupted && isContentLine)
{
contentLinesBeforeInterrupt++;
}
// Phase 2: Wait for enough content, then interrupt
// Interrupt after 2 lines to maximize chance of catching stream while active
// (streams can complete very quickly, so we need to interrupt early)
if (foundConversationStart && !interrupted && contentLinesBeforeInterrupt >= 2)
{
this._outputHelper.WriteLine($"Interrupting stream after {contentLinesBeforeInterrupt} content lines");
interrupted = true;
interruptTime = DateTime.Now;
// Send Enter to interrupt the stream
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
// Give the cancellation token a moment to be processed
// Use a longer delay to ensure cancellation propagates
await Task.Delay(TimeSpan.FromMilliseconds(300), testTimeoutCts.Token);
}
// Phase 3: Look for "Last cursor" message after interrupt
if (interrupted && !resumed && line.Contains("Last cursor", StringComparison.OrdinalIgnoreCase))
{
foundLastCursor = true;
// Send Enter again to resume
this._outputHelper.WriteLine("Resuming stream from last cursor");
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
resumed = true;
}
// Phase 4: Look for resume message
if (resumed && line.Contains("Resuming conversation", StringComparison.OrdinalIgnoreCase))
{
foundResumeMessage = true;
}
// Phase 5: Collect content after resume
if (resumed && isContentLine)
{
contentLinesAfterResume++;
}
// Look for completion message - but don't break if we interrupted and haven't found Last cursor yet
// Allow some time after interrupt for the cancellation message to appear
if (line.Contains("Conversation completed", StringComparison.OrdinalIgnoreCase))
{
// If we interrupted but haven't found Last cursor, wait a bit more
if (interrupted && !foundLastCursor && interruptTime.HasValue)
{
TimeSpan timeSinceInterrupt = DateTime.Now - interruptTime.Value;
if (timeSinceInterrupt < TimeSpan.FromSeconds(2))
{
// Continue reading for a bit more to catch the cancellation message
this._outputHelper.WriteLine("Stream completed naturally, but waiting for Last cursor message after interrupt...");
continue;
}
}
// Only break if we've completed the test or if stream completed without interruption
if (!interrupted || (resumed && foundResumeMessage && contentLinesAfterResume >= 5))
{
break;
}
}
// Stop once we've verified the interrupt/resume flow works
if (resumed && foundResumeMessage && contentLinesAfterResume >= 5)
{
this._outputHelper.WriteLine($"Successfully verified interrupt/resume: {contentLinesBeforeInterrupt} lines before, {contentLinesAfterResume} lines after");
break;
}
}
// If we interrupted but didn't find Last cursor, wait a bit more for it to appear
if (interrupted && !foundLastCursor && interruptTime.HasValue)
{
TimeSpan timeSinceInterrupt = DateTime.Now - interruptTime.Value;
if (timeSinceInterrupt < TimeSpan.FromSeconds(3))
{
this._outputHelper.WriteLine("Waiting for Last cursor message after interrupt...");
using CancellationTokenSource waitCts = new(TimeSpan.FromSeconds(2));
try
{
while ((line = this.ReadLogLine(logs, waitCts.Token)) != null)
{
if (line.Contains("Last cursor", StringComparison.OrdinalIgnoreCase))
{
foundLastCursor = true;
if (!resumed)
{
this._outputHelper.WriteLine("Resuming stream from last cursor");
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
resumed = true;
}
break;
}
}
}
catch (OperationCanceledException)
{
// Timeout waiting for Last cursor
}
}
}
}
catch (OperationCanceledException)
{
// Timeout - check if we got enough to verify the flow
this._outputHelper.WriteLine($"Read timeout reached. Interrupted: {interrupted}, Resumed: {resumed}, Content before: {contentLinesBeforeInterrupt}, Content after: {contentLinesAfterResume}");
}
Assert.True(foundConversationStart, "Conversation start message not found.");
Assert.True(contentLinesBeforeInterrupt >= 2, $"Not enough content before interrupt (got {contentLinesBeforeInterrupt}).");
// If stream completed before interrupt could take effect, that's a timing issue
// but we should still verify we got the conversation started
if (!interrupted)
{
this._outputHelper.WriteLine("WARNING: Stream completed before interrupt could be sent. This may indicate the stream is too fast.");
}
Assert.True(interrupted, "Stream was not interrupted (may have completed too quickly).");
Assert.True(foundLastCursor, "'Last cursor' message not found after interrupt.");
Assert.True(resumed, "Stream was not resumed.");
Assert.True(foundResumeMessage, "Resume message not found.");
Assert.True(contentLinesAfterResume > 0, "No content received after resume (expected to continue from cursor, not restart).");
});
}
private static string GetTargetFramework()
{
string filePath = new Uri(typeof(ConsoleAppSamplesValidation).Assembly.Location).LocalPath;
string directory = Path.GetDirectoryName(filePath)!;
string tfm = Path.GetFileName(directory);
if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase))
{
return tfm;
}
throw new InvalidOperationException($"Unable to find target framework in path: {filePath}");
}
private async Task StartSharedInfrastructureAsync()
{
this._outputHelper.WriteLine("Starting shared infrastructure for console app samples...");
// Start DTS emulator
await this.StartDtsEmulatorAsync();
// Start Redis
await this.StartRedisAsync();
// Wait for infrastructure to be ready
await Task.Delay(TimeSpan.FromSeconds(5));
}
private async Task StartDtsEmulatorAsync()
{
// Start DTS emulator if it's not already running
if (!await this.IsDtsEmulatorRunningAsync())
{
this._outputHelper.WriteLine("Starting DTS emulator...");
await this.RunCommandAsync("docker", [
"run", "-d",
"--name", "dts-emulator",
"-p", $"{DtsPort}:8080",
"-e", "DTS_USE_DYNAMIC_TASK_HUBS=true",
"mcr.microsoft.com/dts/dts-emulator:latest"
]);
}
}
private async Task StartRedisAsync()
{
if (!await this.IsRedisRunningAsync())
{
this._outputHelper.WriteLine("Starting Redis...");
await this.RunCommandAsync("docker", [
"run", "-d",
"--name", "redis",
"-p", $"{RedisPort}:6379",
"redis:latest"
]);
}
}
private async Task<bool> IsDtsEmulatorRunningAsync()
{
this._outputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz...");
// DTS emulator doesn't support HTTP/1.1, so we need to use HTTP/2.0
using HttpClient http2Client = new()
{
DefaultRequestVersion = new Version(2, 0),
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
};
try
{
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
using HttpResponseMessage response = await http2Client.GetAsync(new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token);
if (response.Content.Headers.ContentLength > 0)
{
string content = await response.Content.ReadAsStringAsync(timeoutCts.Token);
this._outputHelper.WriteLine($"DTS emulator health check response: {content}");
}
if (response.IsSuccessStatusCode)
{
this._outputHelper.WriteLine("DTS emulator is running");
return true;
}
this._outputHelper.WriteLine($"DTS emulator is not running. Status code: {response.StatusCode}");
return false;
}
catch (HttpRequestException ex)
{
this._outputHelper.WriteLine($"DTS emulator is not running: {ex.Message}");
return false;
}
}
private async Task<bool> IsRedisRunningAsync()
{
this._outputHelper.WriteLine($"Checking if Redis is running at localhost:{RedisPort}...");
try
{
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
ProcessStartInfo startInfo = new()
{
FileName = "docker",
Arguments = "exec redis redis-cli ping",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = new() { StartInfo = startInfo };
if (!process.Start())
{
this._outputHelper.WriteLine("Failed to start docker exec command");
return false;
}
string output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
await process.WaitForExitAsync(timeoutCts.Token);
if (process.ExitCode == 0 && output.Contains("PONG", StringComparison.OrdinalIgnoreCase))
{
this._outputHelper.WriteLine("Redis is running");
return true;
}
this._outputHelper.WriteLine($"Redis is not running. Exit code: {process.ExitCode}, Output: {output}");
return false;
}
catch (Exception ex)
{
this._outputHelper.WriteLine($"Redis is not running: {ex.Message}");
return false;
}
}
private async Task RunSampleTestAsync(string samplePath, Func<Process, BlockingCollection<OutputLog>, Task> testAction)
{
// Generate a unique TaskHub name for this sample test to prevent cross-test interference
// when multiple tests run together and share the same DTS emulator.
string uniqueTaskHubName = $"sample-{Guid.NewGuid().ToString("N").Substring(0, 6)}";
// Start the console app
// Use BlockingCollection to safely read logs asynchronously captured from the process
using BlockingCollection<OutputLog> logsContainer = [];
using Process appProcess = this.StartConsoleApp(samplePath, logsContainer, uniqueTaskHubName);
try
{
// Run the test
await testAction(appProcess, logsContainer);
}
catch (OperationCanceledException e)
{
throw new TimeoutException("Core test logic timed out!", e);
}
finally
{
logsContainer.CompleteAdding();
await this.StopProcessAsync(appProcess);
}
}
private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message);
/// <summary>
/// Writes a line to the process's stdin and flushes it.
/// Logs the input being sent for debugging purposes.
/// </summary>
private async Task WriteInputAsync(Process process, string input, CancellationToken cancellationToken)
{
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{process.ProcessName}(in)]: {input}");
await process.StandardInput.WriteLineAsync(input);
await process.StandardInput.FlushAsync(cancellationToken);
}
/// <summary>
/// Reads a line from the logs queue, filtering for Information level logs (stdout).
/// Returns null if the collection is completed and empty, or if cancellation is requested.
/// </summary>
private string? ReadLogLine(BlockingCollection<OutputLog> logs, CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
// Block until a log entry is available or cancellation is requested
// Take will throw OperationCanceledException if cancelled, or InvalidOperationException if collection is completed
OutputLog log = logs.Take(cancellationToken);
// Check for unhandled exceptions in the logs, which are never expected (but can happen)
if (log.Message.Contains("Unhandled exception"))
{
Assert.Fail("Console app encountered an unhandled exception.");
}
// Only return Information level logs (stdout), skip Error logs (stderr)
if (log.Level == LogLevel.Information)
{
return log.Message;
}
}
}
catch (OperationCanceledException)
{
// Cancellation requested
return null;
}
catch (InvalidOperationException)
{
// Collection is completed and empty
return null;
}
return null;
}
private Process StartConsoleApp(string samplePath, BlockingCollection<OutputLog> logs, string taskHubName)
{
ProcessStartInfo startInfo = new()
{
FileName = "dotnet",
Arguments = $"run --framework {s_dotnetTargetFramework}",
WorkingDirectory = samplePath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
};
string openAiEndpoint = s_configuration["AZURE_OPENAI_ENDPOINT"] ??
throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set.");
string openAiDeployment = s_configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ??
throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set.");
void SetAndLogEnvironmentVariable(string key, string value)
{
this._outputHelper.WriteLine($"Setting environment variable for {startInfo.FileName} sub-process: {key}={value}");
startInfo.EnvironmentVariables[key] = value;
}
// Set required environment variables for the app
SetAndLogEnvironmentVariable("AZURE_OPENAI_ENDPOINT", openAiEndpoint);
SetAndLogEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT", openAiDeployment);
SetAndLogEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
$"Endpoint=http://localhost:{DtsPort};TaskHub={taskHubName};Authentication=None");
SetAndLogEnvironmentVariable("REDIS_CONNECTION_STRING", $"localhost:{RedisPort}");
Process process = new() { StartInfo = startInfo };
// Capture the output and error streams asynchronously
// These events fire asynchronously, so we add to the blocking collection which is thread-safe
process.ErrorDataReceived += (sender, e) =>
{
if (e.Data != null)
{
string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{startInfo.FileName}(err)]: {e.Data}";
this._outputHelper.WriteLine(logMessage);
Debug.WriteLine(logMessage);
try
{
logs.Add(new OutputLog(DateTime.Now, LogLevel.Error, e.Data));
}
catch (InvalidOperationException)
{
// Collection is completed, ignore
}
}
};
process.OutputDataReceived += (sender, e) =>
{
if (e.Data != null)
{
string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{startInfo.FileName}(out)]: {e.Data}";
this._outputHelper.WriteLine(logMessage);
Debug.WriteLine(logMessage);
try
{
logs.Add(new OutputLog(DateTime.Now, LogLevel.Information, e.Data));
}
catch (InvalidOperationException)
{
// Collection is completed, ignore
}
}
};
if (!process.Start())
{
throw new InvalidOperationException("Failed to start the console app");
}
process.BeginErrorReadLine();
process.BeginOutputReadLine();
return process;
}
private async Task RunCommandAsync(string command, string[] args)
{
await this.RunCommandAsync(command, workingDirectory: null, args: args);
}
private async Task RunCommandAsync(string command, string? workingDirectory, string[] args)
{
ProcessStartInfo startInfo = new()
{
FileName = command,
Arguments = string.Join(" ", args),
WorkingDirectory = workingDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
this._outputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}");
using Process process = new() { StartInfo = startInfo };
process.ErrorDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(err)]: {e.Data}");
process.OutputDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(out)]: {e.Data}");
if (!process.Start())
{
throw new InvalidOperationException("Failed to start the command");
}
process.BeginErrorReadLine();
process.BeginOutputReadLine();
using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(1));
await process.WaitForExitAsync(cancellationTokenSource.Token);
this._outputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}");
}
private async Task StopProcessAsync(Process process)
{
try
{
if (!process.HasExited)
{
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Killing process {process.ProcessName}#{process.Id}");
process.Kill(entireProcessTree: true);
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(10));
await process.WaitForExitAsync(timeoutCts.Token);
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Process exited: {process.Id}");
}
}
catch (Exception ex)
{
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Failed to stop process: {ex.Message}");
}
}
private CancellationTokenSource CreateTestTimeoutCts(TimeSpan? timeout = null)
{
TimeSpan testTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : timeout ?? TimeSpan.FromSeconds(60);
return new CancellationTokenSource(testTimeout);
}
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Diagnostics;
using System.Reflection;
using Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using OpenAI.Chat;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
/// <summary>
/// Tests for scenarios where an external client interacts with Durable Task Agents.
/// </summary>
[Collection("Sequential")]
[Trait("Category", "Integration")]
public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDisposable
{
private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached
? TimeSpan.FromMinutes(5)
: TimeSpan.FromSeconds(30);
private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
private readonly ITestOutputHelper _outputHelper = outputHelper;
private readonly CancellationTokenSource _cts = new(delay: s_defaultTimeout);
private CancellationToken TestTimeoutToken => this._cts.Token;
public void Dispose() => this._cts.Dispose();
[Fact]
public async Task SimplePromptAsync()
{
// Setup
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent(
instructions: "You are a helpful assistant that always responds with a friendly greeting.",
name: "TestAgent");
using TestHelper testHelper = TestHelper.Start([simpleAgent], this._outputHelper);
// A proxy agent is needed to call the hosted test agent
AIAgent simpleAgentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
// Act: send a prompt to the agent and wait for a response
AgentThread thread = await simpleAgentProxy.GetNewThreadAsync(this.TestTimeoutToken);
await simpleAgentProxy.RunAsync(
message: "Hello!",
thread,
cancellationToken: this.TestTimeoutToken);
AgentResponse response = await simpleAgentProxy.RunAsync(
message: "Repeat what you just said but say it like a pirate",
thread,
cancellationToken: this.TestTimeoutToken);
// Assert: verify the agent responded appropriately
// We can't predict the exact response, but we can check that there is one response
Assert.NotNull(response);
Assert.NotEmpty(response.Text);
// Assert: verify the expected log entries were created in the expected category
IReadOnlyCollection<LogEntry> logs = testHelper.GetLogs();
Assert.NotEmpty(logs);
List<LogEntry> agentLogs = [.. logs.Where(log => log.Category.Contains(simpleAgent.Name!)).ToList()];
Assert.NotEmpty(agentLogs);
Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentRequest" && log.Message.Contains("Hello!"));
Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentResponse");
}
[Fact]
public async Task CallFunctionToolsAsync()
{
int weatherToolInvocationCount = 0;
int packingListToolInvocationCount = 0;
string GetWeather(string location)
{
weatherToolInvocationCount++;
return $"The weather in {location} is sunny with a high of 75°F and a low of 55°F.";
}
string SuggestPackingList(string weather, bool isSunny)
{
packingListToolInvocationCount++;
return isSunny ? "Pack sunglasses and sunscreen." : "Pack a raincoat and umbrella.";
}
AIAgent tripPlanningAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent(
instructions: "You are a trip planning assistant. Use the weather tool and packing list tool as needed.",
name: "TripPlanningAgent",
description: "An agent to help plan your day trips",
tools: [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(SuggestPackingList)]
);
using TestHelper testHelper = TestHelper.Start([tripPlanningAgent], this._outputHelper);
AIAgent tripPlanningAgentProxy = tripPlanningAgent.AsDurableAgentProxy(testHelper.Services);
// Act: send a prompt to the agent
AgentResponse response = await tripPlanningAgentProxy.RunAsync(
message: "Help me figure out what to pack for my Seattle trip next Sunday",
cancellationToken: this.TestTimeoutToken);
// Assert: verify the agent responded appropriately
// We can't predict the exact response, but we can check that there is one response
Assert.NotNull(response);
Assert.NotEmpty(response.Text);
// Assert: verify the expected log entries were created in the expected category
IReadOnlyCollection<LogEntry> logs = testHelper.GetLogs();
Assert.NotEmpty(logs);
List<LogEntry> agentLogs = [.. logs.Where(log => log.Category.Contains(tripPlanningAgent.Name!)).ToList()];
Assert.NotEmpty(agentLogs);
Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentRequest" && log.Message.Contains("Seattle trip"));
Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentResponse");
// Assert: verify the tools were called
Assert.Equal(1, weatherToolInvocationCount);
Assert.Equal(1, packingListToolInvocationCount);
}
[Fact]
public async Task CallLongRunningFunctionToolsAsync()
{
[Description("Starts a greeting workflow and returns the workflow instance ID")]
string StartWorkflowTool(string name)
{
return DurableAgentContext.Current.ScheduleNewOrchestration(nameof(RunWorkflowAsync), input: name);
}
[Description("Gets the current status of a previously started workflow. A null response means the workflow has not started yet.")]
static async Task<OrchestrationMetadata?> GetWorkflowStatusToolAsync(string instanceId)
{
OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync(
instanceId,
includeDetails: true);
if (status == null)
{
// If the status is not found, wait a bit before returning null to give the workflow time to start
await Task.Delay(TimeSpan.FromSeconds(1));
}
return status;
}
async Task<string> RunWorkflowAsync(TaskOrchestrationContext context, string name)
{
// 1. Get agent and create a session
DurableAIAgent agent = context.GetAgent("SimpleAgent");
AgentThread thread = await agent.GetNewThreadAsync(this.TestTimeoutToken);
// 2. Call an agent and tell it my name
await agent.RunAsync($"My name is {name}.", thread);
// 3. Call the agent again with the same thread (ask it to tell me my name)
AgentResponse response = await agent.RunAsync("What is my name?", thread);
return response.Text;
}
using TestHelper testHelper = TestHelper.Start(
this._outputHelper,
configureAgents: agents =>
{
// This is the agent that will be used to start the workflow
agents.AddAIAgentFactory(
"WorkflowAgent",
sp => TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent(
name: "WorkflowAgent",
instructions: "You can start greeting workflows and check their status.",
services: sp,
tools: [
AIFunctionFactory.Create(StartWorkflowTool),
AIFunctionFactory.Create(GetWorkflowStatusToolAsync)
]));
// This is the agent that will be called by the workflow
agents.AddAIAgent(TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent(
name: "SimpleAgent",
instructions: "You are a simple assistant."
));
},
durableTaskRegistry: registry => registry.AddOrchestratorFunc<string, string>(nameof(RunWorkflowAsync), RunWorkflowAsync));
AIAgent workflowManagerAgentProxy = testHelper.Services.GetDurableAgentProxy("WorkflowAgent");
// Act: send a prompt to the agent
AgentThread thread = await workflowManagerAgentProxy.GetNewThreadAsync(this.TestTimeoutToken);
await workflowManagerAgentProxy.RunAsync(
message: "Start a greeting workflow for \"John Doe\".",
thread,
cancellationToken: this.TestTimeoutToken);
// Act: prompt it again to wait for the workflow to complete
AgentResponse response = await workflowManagerAgentProxy.RunAsync(
message: "Wait for the workflow to complete and tell me the result.",
thread,
cancellationToken: this.TestTimeoutToken);
// Assert: verify the agent responded appropriately
// We can't predict the exact response, but we can check that there is one response
Assert.NotNull(response);
Assert.NotEmpty(response.Text);
Assert.Contains("John Doe", response.Text);
}
[Fact]
public void AsDurableAgentProxy_ThrowsWhenAgentNotRegistered()
{
// Setup: Register one agent but try to use a different one
AIAgent registeredAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent(
instructions: "You are a helpful assistant.",
name: "RegisteredAgent");
using TestHelper testHelper = TestHelper.Start([registeredAgent], this._outputHelper);
// Create an agent with a different name that isn't registered
AIAgent unregisteredAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent(
instructions: "You are a helpful assistant.",
name: "UnregisteredAgent");
// Act & Assert: Should throw AgentNotRegisteredException
AgentNotRegisteredException exception = Assert.Throws<AgentNotRegisteredException>(
() => unregisteredAgent.AsDurableAgentProxy(testHelper.Services));
Assert.Equal("UnregisteredAgent", exception.AgentName);
}
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
internal sealed class LogEntry(
string category,
LogLevel level,
EventId eventId,
Exception? exception,
string message,
object? state,
IReadOnlyList<KeyValuePair<string, object?>> contextProperties)
{
public string Category { get; } = category;
public DateTime Timestamp { get; } = DateTime.Now;
public EventId EventId { get; } = eventId;
public LogLevel LogLevel { get; } = level;
public Exception? Exception { get; } = exception;
public string Message { get; } = message;
public object? State { get; } = state;
public IReadOnlyList<KeyValuePair<string, object?>> ContextProperties { get; } = contextProperties;
public override string ToString()
{
string properties = this.ContextProperties.Count > 0
? $"[{string.Join(", ", this.ContextProperties.Select(kvp => $"{kvp.Key}={kvp.Value}"))}] "
: string.Empty;
string eventName = this.EventId.Name ?? string.Empty;
string output = $"{this.Timestamp:o} [{this.Category}] {eventName} {properties}{this.Message}";
if (this.Exception is not null)
{
output += Environment.NewLine + this.Exception;
}
return output;
}
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
internal sealed class TestLogger(string category, ITestOutputHelper output) : ILogger
{
private readonly string _category = category;
private readonly ITestOutputHelper _output = output;
private readonly ConcurrentQueue<LogEntry> _entries = new();
public IReadOnlyCollection<LogEntry> GetLogs() => this._entries;
public void ClearLogs() => this._entries.Clear();
IDisposable? ILogger.BeginScope<TState>(TState state) => null;
bool ILogger.IsEnabled(LogLevel logLevel) => true;
void ILogger.Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
LogEntry entry = new(
category: this._category,
level: logLevel,
eventId: eventId,
exception: exception,
message: formatter(state, exception),
state: state,
contextProperties: []);
this._entries.Enqueue(entry);
try
{
this._output.WriteLine(entry.ToString());
}
catch (InvalidOperationException)
{
// Expected when tests are shutting down
}
}
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
internal sealed class TestLoggerProvider(ITestOutputHelper output) : ILoggerProvider
{
private readonly ITestOutputHelper _output = output ?? throw new ArgumentNullException(nameof(output));
private readonly ConcurrentDictionary<string, TestLogger> _loggers = new(StringComparer.OrdinalIgnoreCase);
public bool TryGetLogs(string category, out IReadOnlyCollection<LogEntry> logs)
{
if (this._loggers.TryGetValue(category, out TestLogger? logger))
{
logs = logger.GetLogs();
return true;
}
logs = [];
return false;
}
public IReadOnlyCollection<LogEntry> GetAllLogs()
{
return this._loggers.Values
.OfType<TestLogger>()
.SelectMany(logger => logger.GetLogs())
.ToList()
.AsReadOnly();
}
public void Clear()
{
foreach (TestLogger logger in this._loggers.Values.OfType<TestLogger>())
{
logger.ClearLogs();
}
}
ILogger ILoggerProvider.CreateLogger(string categoryName)
{
return this._loggers.GetOrAdd(categoryName, _ => new TestLogger(categoryName, this._output));
}
void IDisposable.Dispose()
{
// no-op
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>b7762d10-e29b-4bb1-8b74-b6d69a667dd4</UserSecretsId>
</PropertyGroup>
<!-- Public packages required by integration tests -->
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Reflection;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using OpenAI.Chat;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
/// <summary>
/// Tests for orchestration execution scenarios with Durable Task Agents.
/// </summary>
[Collection("Sequential")]
[Trait("Category", "Integration")]
public sealed class OrchestrationTests(ITestOutputHelper outputHelper) : IDisposable
{
private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached
? TimeSpan.FromMinutes(5)
: TimeSpan.FromSeconds(30);
private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
private readonly ITestOutputHelper _outputHelper = outputHelper;
private readonly CancellationTokenSource _cts = new(delay: s_defaultTimeout);
private CancellationToken TestTimeoutToken => this._cts.Token;
public void Dispose() => this._cts.Dispose();
[Fact]
public async Task GetAgent_ThrowsWhenAgentNotRegisteredAsync()
{
// Define an orchestration that tries to use an unregistered agent
static async Task<string> TestOrchestrationAsync(TaskOrchestrationContext context)
{
// Get an agent that hasn't been registered
DurableAIAgent agent = context.GetAgent("NonExistentAgent");
// This should throw when RunAsync is called because the agent doesn't exist
await agent.RunAsync("Hello");
return "Should not reach here";
}
// Setup: Create test helper without registering "NonExistentAgent"
using TestHelper testHelper = TestHelper.Start(
this._outputHelper,
configureAgents: agents =>
{
// Register a different agent, but not "NonExistentAgent"
agents.AddAIAgentFactory(
"OtherAgent",
sp => TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent(
name: "OtherAgent",
instructions: "You are a test agent."));
},
durableTaskRegistry: registry =>
registry.AddOrchestratorFunc(
name: nameof(TestOrchestrationAsync),
orchestrator: TestOrchestrationAsync));
DurableTaskClient client = testHelper.GetClient();
// Act: Start the orchestration
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: nameof(TestOrchestrationAsync),
cancellation: this.TestTimeoutToken);
// Wait for the orchestration to complete and check for failure
OrchestrationMetadata status = await client.WaitForInstanceCompletionAsync(
instanceId,
getInputsAndOutputs: true,
this.TestTimeoutToken);
// Assert: Verify the orchestration failed with the expected exception
Assert.NotNull(status);
Assert.Equal(OrchestrationRuntimeStatus.Failed, status.RuntimeStatus);
Assert.NotNull(status.FailureDetails);
// Verify the exception type is AgentNotRegisteredException
Assert.True(
status.FailureDetails.ErrorType == typeof(AgentNotRegisteredException).FullName,
$"Expected AgentNotRegisteredException but got ErrorType: {status.FailureDetails.ErrorType}, Message: {status.FailureDetails.ErrorMessage}");
// Verify the exception message contains the agent name
Assert.Contains("NonExistentAgent", status.FailureDetails.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
internal sealed class TestHelper : IDisposable
{
private readonly TestLoggerProvider _loggerProvider;
private readonly IHost _host;
private readonly DurableTaskClient _client;
// The static Start method should be used to create instances of this class.
private TestHelper(
TestLoggerProvider loggerProvider,
IHost host,
DurableTaskClient client)
{
this._loggerProvider = loggerProvider;
this._host = host;
this._client = client;
}
public IServiceProvider Services => this._host.Services;
public void Dispose()
{
this._host.Dispose();
}
public bool TryGetLogs(string category, out IReadOnlyCollection<LogEntry> logs)
=> this._loggerProvider.TryGetLogs(category, out logs);
public static TestHelper Start(
AIAgent[] agents,
ITestOutputHelper outputHelper,
Action<DurableTaskRegistry>? durableTaskRegistry = null)
{
return BuildAndStartTestHelper(
outputHelper,
options => options.AddAIAgents(agents),
durableTaskRegistry);
}
public static TestHelper Start(
ITestOutputHelper outputHelper,
Action<DurableAgentsOptions> configureAgents,
Action<DurableTaskRegistry>? durableTaskRegistry = null)
{
return BuildAndStartTestHelper(
outputHelper,
configureAgents,
durableTaskRegistry);
}
public DurableTaskClient GetClient() => this._client;
private static TestHelper BuildAndStartTestHelper(
ITestOutputHelper outputHelper,
Action<DurableAgentsOptions> configureAgents,
Action<DurableTaskRegistry>? durableTaskRegistry)
{
TestLoggerProvider loggerProvider = new(outputHelper);
// Generate a unique TaskHub name for this test instance to prevent cross-test interference
// when multiple tests run together and share the same DTS emulator.
string uniqueTaskHubName = $"test-{Guid.NewGuid().ToString("N").Substring(0, 6)}";
IHost host = Host.CreateDefaultBuilder()
.ConfigureServices((ctx, services) =>
{
string dtsConnectionString = GetDurableTaskSchedulerConnectionString(ctx.Configuration, uniqueTaskHubName);
// Register durable agents using the caller-supplied registration action and
// apply the default chat client for agents that don't supply one themselves.
services.ConfigureDurableAgents(
options => configureAgents(options),
workerBuilder: builder =>
{
builder.UseDurableTaskScheduler(dtsConnectionString);
if (durableTaskRegistry != null)
{
builder.AddTasks(durableTaskRegistry);
}
},
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.ConfigureLogging((_, logging) =>
{
logging.AddProvider(loggerProvider);
logging.SetMinimumLevel(LogLevel.Debug);
})
.Build();
host.Start();
DurableTaskClient client = host.Services.GetRequiredService<DurableTaskClient>();
return new TestHelper(loggerProvider, host, client);
}
private static string GetDurableTaskSchedulerConnectionString(IConfiguration configuration, string? taskHubName = null)
{
// The default value is for local development using the Durable Task Scheduler emulator.
string? connectionString = configuration["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"];
if (connectionString != null)
{
// If a connection string is provided, replace the TaskHub name if a custom one is specified
if (taskHubName != null)
{
// Replace TaskHub in the connection string
if (connectionString.Contains("TaskHub=", StringComparison.OrdinalIgnoreCase))
{
// Find and replace the TaskHub value
int taskHubIndex = connectionString.IndexOf("TaskHub=", StringComparison.OrdinalIgnoreCase);
int taskHubValueStart = taskHubIndex + "TaskHub=".Length;
int taskHubValueEnd = connectionString.IndexOf(';', taskHubValueStart);
if (taskHubValueEnd == -1)
{
taskHubValueEnd = connectionString.Length;
}
connectionString = string.Concat(
connectionString.AsSpan(0, taskHubValueStart),
taskHubName,
connectionString.AsSpan(taskHubValueEnd));
}
else
{
// Append TaskHub if it doesn't exist
connectionString += $";TaskHub={taskHubName}";
}
}
return connectionString;
}
// Default connection string with unique TaskHub name
string defaultTaskHub = taskHubName ?? "default";
return $"Endpoint=http://localhost:8080;TaskHub={defaultTaskHub};Authentication=None";
}
internal static ChatClient GetAzureOpenAIChatClient(IConfiguration configuration)
{
string azureOpenAiEndpoint = configuration["AZURE_OPENAI_ENDPOINT"] ??
throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set.");
string azureOpenAiDeploymentName = configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ??
throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set.");
// Check if AZURE_OPENAI_KEY is provided for key-based authentication.
// NOTE: This is not used for automated tests, but can be useful for local development.
string? azureOpenAiKey = configuration["AZURE_OPENAI_KEY"];
AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
? new AzureOpenAIClient(new Uri(azureOpenAiEndpoint), new AzureKeyCredential(azureOpenAiKey))
: new AzureOpenAIClient(new Uri(azureOpenAiEndpoint), new AzureCliCredential());
return client.GetChatClient(azureOpenAiDeploymentName);
}
internal IReadOnlyCollection<LogEntry> GetLogs()
{
return this._loggerProvider.GetAllLogs();
}
}

View File

@@ -0,0 +1,197 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Reflection;
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.Entities;
using Microsoft.Extensions.Configuration;
using OpenAI.Chat;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
/// <summary>
/// Tests for Time-To-Live (TTL) functionality of durable agent entities.
/// </summary>
[Collection("Sequential")]
[Trait("Category", "Integration")]
public sealed class TimeToLiveTests(ITestOutputHelper outputHelper) : IDisposable
{
private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached
? TimeSpan.FromMinutes(5)
: TimeSpan.FromSeconds(30);
private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
private readonly ITestOutputHelper _outputHelper = outputHelper;
private readonly CancellationTokenSource _cts = new(delay: s_defaultTimeout);
private CancellationToken TestTimeoutToken => this._cts.Token;
public void Dispose() => this._cts.Dispose();
[Fact]
public async Task EntityExpiresAfterTTLAsync()
{
// Arrange: Create agent with short TTL (10 seconds)
TimeSpan ttl = TimeSpan.FromSeconds(10);
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent(
name: "TTLTestAgent",
instructions: "You are a helpful assistant."
);
using TestHelper testHelper = TestHelper.Start(
this._outputHelper,
options =>
{
options.DefaultTimeToLive = ttl;
options.MinimumTimeToLiveSignalDelay = TimeSpan.FromSeconds(1);
options.AddAIAgent(simpleAgent);
});
AIAgent agentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
AgentThread thread = await agentProxy.GetNewThreadAsync(this.TestTimeoutToken);
DurableTaskClient client = testHelper.GetClient();
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
// Act: Send a message to the agent
await agentProxy.RunAsync(
message: "Hello!",
thread,
cancellationToken: this.TestTimeoutToken);
// Verify entity exists and get expiration time
EntityMetadata? entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
DurableAgentState state = entity.State.ReadAs<DurableAgentState>();
Assert.NotNull(state.Data.ExpirationTimeUtc);
DateTime expirationTime = state.Data.ExpirationTimeUtc.Value;
Assert.True(expirationTime > DateTime.UtcNow);
// Calculate how long to wait: expiration time + buffer for signal processing
TimeSpan waitTime = expirationTime - DateTime.UtcNow + TimeSpan.FromSeconds(1);
if (waitTime > TimeSpan.Zero)
{
await Task.Delay(waitTime, this.TestTimeoutToken);
}
// Poll the entity state until it's deleted (with timeout)
DateTime pollTimeout = DateTime.UtcNow.AddSeconds(10);
bool entityDeleted = false;
while (DateTime.UtcNow < pollTimeout && !entityDeleted)
{
entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
entityDeleted = entity is null;
if (!entityDeleted)
{
await Task.Delay(TimeSpan.FromSeconds(1), this.TestTimeoutToken);
}
}
// Assert: Verify entity state is deleted
Assert.True(entityDeleted, "Entity should have been deleted after TTL expiration");
}
[Fact]
public async Task EntityTTLResetsOnInteractionAsync()
{
// Arrange: Create agent with short TTL
TimeSpan ttl = TimeSpan.FromSeconds(6);
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).AsAIAgent(
name: "TTLResetTestAgent",
instructions: "You are a helpful assistant."
);
using TestHelper testHelper = TestHelper.Start(
this._outputHelper,
options =>
{
options.DefaultTimeToLive = ttl;
options.MinimumTimeToLiveSignalDelay = TimeSpan.FromSeconds(1);
options.AddAIAgent(simpleAgent);
});
AIAgent agentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
AgentThread thread = await agentProxy.GetNewThreadAsync(this.TestTimeoutToken);
DurableTaskClient client = testHelper.GetClient();
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
// Act: Send first message
await agentProxy.RunAsync(
message: "Hello!",
thread,
cancellationToken: this.TestTimeoutToken);
EntityMetadata? entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
DurableAgentState state = entity.State.ReadAs<DurableAgentState>();
DateTime firstExpirationTime = state.Data.ExpirationTimeUtc!.Value;
// Wait partway through TTL
await Task.Delay(TimeSpan.FromSeconds(3), this.TestTimeoutToken);
// Send second message (should reset TTL)
await agentProxy.RunAsync(
message: "Hello again!",
thread,
cancellationToken: this.TestTimeoutToken);
// Verify expiration time was updated
entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
state = entity.State.ReadAs<DurableAgentState>();
DateTime secondExpirationTime = state.Data.ExpirationTimeUtc!.Value;
Assert.True(secondExpirationTime > firstExpirationTime);
// Calculate when the original expiration time would have been
DateTime originalExpirationTime = firstExpirationTime;
TimeSpan waitUntilOriginalExpiration = originalExpirationTime - DateTime.UtcNow + TimeSpan.FromSeconds(2);
if (waitUntilOriginalExpiration > TimeSpan.Zero)
{
await Task.Delay(waitUntilOriginalExpiration, this.TestTimeoutToken);
}
// Assert: Entity should still exist because TTL was reset
// The new expiration time should be in the future
entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
state = entity.State.ReadAs<DurableAgentState>();
Assert.NotNull(state);
Assert.NotNull(state.Data.ExpirationTimeUtc);
Assert.True(
state.Data.ExpirationTimeUtc > DateTime.UtcNow,
"Entity should still be valid because TTL was reset");
// Wait for the entity to be deleted
DateTime pollTimeout = DateTime.UtcNow.AddSeconds(10);
bool entityDeleted = false;
while (DateTime.UtcNow < pollTimeout && !entityDeleted)
{
entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
entityDeleted = entity is null;
if (!entityDeleted)
{
await Task.Delay(TimeSpan.FromSeconds(1), this.TestTimeoutToken);
}
}
// Assert: Entity should have been deleted
Assert.True(entityDeleted, "Entity should have been deleted after TTL expiration");
}
}