// Copyright (c) Microsoft. All rights reserved. using System; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Agents.AI.Workflows.Execution; namespace Microsoft.Agents.AI.Workflows.UnitTests; public sealed class InputWaiterTests : IDisposable { private readonly InputWaiter _waiter = new(); public void Dispose() { this._waiter.Dispose(); GC.SuppressFinalize(this); } [Fact] public async Task InputWaiter_WaitForInputAsync_CompletesAfterSignalAsync() { this._waiter.SignalInput(); // WaitForInputAsync should complete immediately since input was already signaled Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None); Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(1))); completed.Should().BeSameAs(waitTask, "the wait task should complete before the timeout"); await waitTask; } [Fact] public async Task InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync() { // Use the no-timeout overload so that the wait can only be released by SignalInput. // A finite timeout would make this test's logic racy: the component correctly // honors the timeout, but if the test thread is starved of CPU time (CI load, // GC pause) long enough for the timeout to fire, waitTask completes before // SignalInput is called and the "should not complete before signaled" assertion // flakes. Timeout behavior is covered separately below. Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None); Task completedBeforeSignal = await Task.WhenAny(waitTask, Task.Delay(100)); completedBeforeSignal.Should().NotBeSameAs( waitTask, "the waiter should not complete before input is signaled"); this._waiter.SignalInput(); Task completedAfterSignal = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(1))); completedAfterSignal.Should().BeSameAs( waitTask, "the wait task should complete after being signaled"); await waitTask; } [Fact] public void InputWaiter_SignalInput_DoubleSignalDoesNotThrow() { // Binary semaphore behavior: double signal should be idempotent FluentActions.Invoking(() => { this._waiter.SignalInput(); this._waiter.SignalInput(); }).Should().NotThrow("double signaling should be handled gracefully"); } [Fact] public async Task InputWaiter_WaitForInputAsync_RespectsCancellationAsync() { using CancellationTokenSource cts = new(); Task waitTask = this._waiter.WaitForInputAsync(cts.Token); cts.Cancel(); Func act = () => waitTask; await act.Should().ThrowAsync(); } [Fact] public async Task InputWaiter_WaitForInputAsync_DoesNotCompleteWhenNotSignaledAsync() { using CancellationTokenSource cts = new(); Task waitTask = this._waiter.WaitForInputAsync(cts.Token); Task completed = await Task.WhenAny(waitTask, Task.Delay(100)); completed.Should().NotBeSameAs(waitTask, "the wait task should not complete when input is not signaled"); // Cancel and observe the pending task to avoid an unobserved exception on Dispose cts.Cancel(); try { await waitTask; } catch (OperationCanceledException) { } } [Fact] public async Task InputWaiter_WaitForInputAsync_CanBeSignaledMultipleTimesSequentiallyAsync() { // First signal/wait cycle this._waiter.SignalInput(); await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1)); // Second signal/wait cycle this._waiter.SignalInput(); await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1)); } [Fact] public async Task InputWaiter_WaitForInputAsync_CompletesWhenTimeoutExpiresAsync() { // Verify that a finite timeout releases the block even without a signal. // We only assert that it *does* complete (within a generous outer bound); // we intentionally do not assert that it stays blocked until the timeout, // because that would re-introduce the same wall-clock flakiness // described in BlocksUntilSignaledAsync (see comment on that test). Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromMilliseconds(300)); Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(5))); completed.Should().BeSameAs(waitTask, "the wait task should complete once the timeout expires"); await waitTask; } }