// 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"); } }