// 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;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
///
/// Tests for Time-To-Live (TTL) functionality of durable agent entities.
///
[Collection("Sequential")]
[Trait("Category", "IntegrationDisabled")]
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);
AgentSession session = await agentProxy.CreateSessionAsync(this.TestTimeoutToken);
DurableTaskClient client = testHelper.GetClient();
AgentSessionId sessionId = session.GetService();
// Act: Send a message to the agent
await agentProxy.RunAsync(
message: "Hello!",
session,
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();
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);
AgentSession session = await agentProxy.CreateSessionAsync(this.TestTimeoutToken);
DurableTaskClient client = testHelper.GetClient();
AgentSessionId sessionId = session.GetService();
// Act: Send first message
await agentProxy.RunAsync(
message: "Hello!",
session,
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();
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!",
session,
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();
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();
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");
}
}