// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
///
/// Tests for .
///
public sealed class HttpRequestExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
private const string TestUrl = "https://api.example.com/data";
private readonly Mock _agentProvider = new(MockBehavior.Loose);
[Fact]
public void InvalidModel()
{
// Arrange
Mock mockHandler = new();
// Act & Assert
Assert.Throws(() => new HttpRequestExecutor(
new HttpRequestAction(),
mockHandler.Object,
this._agentProvider.Object,
this.State));
}
[Fact]
public void HttpRequestIsDiscreteAction()
{
// Arrange
Mock mockHandler = new();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestIsDiscreteAction),
url: TestUrl,
method: HttpMethodType.Get);
HttpRequestExecutor action = new(model, mockHandler.Object, this._agentProvider.Object, this.State);
// Act & Assert — IsDiscreteAction should be true for HttpRequest (single-step action).
VerifyIsDiscrete(action, isDiscrete: true);
}
[Fact]
public async Task HttpGetReturnsJsonObjectAsync()
{
// Arrange
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetReturnsJsonObjectAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult("{\"key\":\"value\",\"number\":42}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
Assert.IsType(this.State.Get(ResponseVar), exactMatch: false);
handler.VerifySent(info => info.Method == "GET" && info.Url == TestUrl);
}
[Fact]
public async Task HttpGetReturnsPlainStringAsync()
{
// Arrange
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetReturnsPlainStringAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult("not-json content"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyState(ResponseVar, FormulaValue.New("not-json content"));
}
[Fact]
public async Task HttpGetWithEmptyBodyYieldsBlankAsync()
{
// Arrange
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetWithEmptyBodyYieldsBlankAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult(null));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyUndefined(ResponseVar);
}
[Fact]
public async Task HttpGetForwardsHeadersAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetForwardsHeadersAsync),
url: TestUrl,
method: HttpMethodType.Get,
headers: new Dictionary
{
["Authorization"] = "Bearer token",
["Accept"] = "application/json",
});
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Headers?["Authorization"] == "Bearer token" &&
info.Headers?["Accept"] == "application/json");
}
[Fact]
public async Task HttpPostWithJsonBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpPostWithJsonBodyAsync),
url: TestUrl,
method: HttpMethodType.Post,
jsonBody: new StringDataValue("hello"));
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Method == "POST" &&
info.BodyContentType == "application/json" &&
info.Body == "\"hello\"");
}
[Fact]
public async Task HttpPostWithRawBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpPostWithRawBodyAsync),
url: TestUrl,
method: HttpMethodType.Post,
rawBody: "raw body content",
rawContentType: "text/plain");
MockHttpRequestHandler handler = new(HttpRequestResult(""));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.BodyContentType == "text/plain" &&
info.Body == "raw body content");
}
[Fact]
public async Task HttpRequestRaisesOnErrorByDefaultAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestRaisesOnErrorByDefaultAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult("server error", statusCode: 500, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act & Assert
await Assert.ThrowsAsync(() => this.ExecuteAsync(action));
}
[Fact]
public async Task HttpRequestFailureExceptionTruncatesLongBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestFailureExceptionTruncatesLongBodyAsync),
url: TestUrl,
method: HttpMethodType.Get);
string longBody = new('x', 10_000);
MockHttpRequestHandler handler = new(HttpRequestResult(longBody, statusCode: 500, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
DeclarativeActionException exception =
await Assert.ThrowsAsync(() => this.ExecuteAsync(action));
// Assert - message contains status and truncation marker, bounded in length, never the full body.
Assert.Contains("500", exception.Message);
Assert.Contains("[truncated]", exception.Message);
Assert.DoesNotContain(longBody, exception.Message);
Assert.True(exception.Message.Length < 512, $"Exception message too long: {exception.Message.Length} chars.");
}
[Fact]
public async Task HttpRequestFailureExceptionOmitsEmptyBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestFailureExceptionOmitsEmptyBodyAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult(body: null, statusCode: 404, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
DeclarativeActionException exception =
await Assert.ThrowsAsync(() => this.ExecuteAsync(action));
// Assert - status present, no stray "Body: ''" noise.
Assert.Contains("404", exception.Message);
Assert.DoesNotContain("Body:", exception.Message);
}
[Fact]
public async Task HttpRequestFailureExceptionSanitizesControlCharsAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestFailureExceptionSanitizesControlCharsAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult("line1\r\nline2\tend", statusCode: 400, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
DeclarativeActionException exception =
await Assert.ThrowsAsync(() => this.ExecuteAsync(action));
// Assert - CR/LF/TAB collapsed to spaces so the message stays on one line.
Assert.DoesNotContain("\r", exception.Message);
Assert.DoesNotContain("\n", exception.Message);
Assert.DoesNotContain("\t", exception.Message);
Assert.Contains("line1", exception.Message);
Assert.Contains("line2", exception.Message);
}
[Fact]
public async Task HttpRequestPassesTimeoutToHandlerAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestPassesTimeoutToHandlerAsync),
url: TestUrl,
method: HttpMethodType.Get,
timeoutMilliseconds: 1500);
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Timeout is not null &&
info.Timeout.Value == TimeSpan.FromMilliseconds(1500));
}
[Fact]
public async Task HttpRequestTimeoutRaisesDeclarativeExceptionAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestTimeoutRaisesDeclarativeExceptionAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(
HttpRequestResult("{}"),
throwOnSend: new OperationCanceledException());
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act & Assert
await Assert.ThrowsAsync(() => this.ExecuteAsync(action));
}
[Fact]
public async Task HttpRequestTransportFailureRaisesDeclarativeExceptionAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestTransportFailureRaisesDeclarativeExceptionAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(
HttpRequestResult("{}"),
throwOnSend: new InvalidOperationException("transport failure"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act & Assert
await Assert.ThrowsAsync(() => this.ExecuteAsync(action));
}
[Fact]
public async Task HttpRequestStoresResponseHeadersAsync()
{
// Arrange
this.State.InitializeSystem();
const string HeaderVar = "Headers";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestStoresResponseHeadersAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseHeadersVariable: HeaderVar);
Dictionary> responseHeaders = new(StringComparer.OrdinalIgnoreCase)
{
["X-Request-Id"] = ["abc-123"],
["Set-Cookie"] = ["a=1", "b=2"],
};
MockHttpRequestHandler handler = new(HttpRequestResult("{}", headers: responseHeaders));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
FormulaValue storedHeaders = this.State.Get(HeaderVar);
Assert.IsType(storedHeaders, exactMatch: false);
}
[Fact]
public async Task HttpRequestForwardsQueryParametersAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestForwardsQueryParametersAsync),
url: TestUrl,
method: HttpMethodType.Get,
queryParameters: new Dictionary
{
["filter"] = StringDataValue.Create("active"),
["limit"] = NumberDataValue.Create(10),
["includeDeleted"] = BooleanDataValue.Create(false),
});
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.QueryParameters?.Count == 3 &&
info.QueryParameters["filter"] == "active" &&
info.QueryParameters["limit"] == "10" &&
info.QueryParameters["includeDeleted"] == "false");
}
[Fact]
public async Task HttpRequestAddsResponseToConversationAsync()
{
// Arrange
this.State.InitializeSystem();
const string ConversationId = "conv-12345";
const string ResponseBody = "response-text";
this._agentProvider
.Setup(p => p.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.Returns((_, message, _) => Task.FromResult(message));
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestAddsResponseToConversationAsync),
url: TestUrl,
method: HttpMethodType.Get,
conversationId: ConversationId);
MockHttpRequestHandler handler = new(HttpRequestResult(ResponseBody));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(
ConversationId,
It.Is(m => m.Role == ChatRole.Assistant && m.Text == ResponseBody),
It.IsAny()),
Times.Once);
}
[Fact]
public async Task HttpRequestWithoutConversationIdSkipsConversationAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestWithoutConversationIdSkipsConversationAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult("response"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()),
Times.Never);
}
[Fact]
public async Task HttpRequestForwardsConnectionNameAsync()
{
// Arrange
this.State.InitializeSystem();
const string ConnectionName = "my-connection";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestForwardsConnectionNameAsync),
url: TestUrl,
method: HttpMethodType.Get,
connectionName: ConnectionName);
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info => info.ConnectionName == ConnectionName);
}
[Fact]
public async Task HttpRequestEmptyConversationIdSkipsConversationAsync()
{
// Arrange - empty-string conversationId should be treated as unset.
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestEmptyConversationIdSkipsConversationAsync),
url: TestUrl,
method: HttpMethodType.Get,
conversationId: "");
MockHttpRequestHandler handler = new(HttpRequestResult("response"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()),
Times.Never);
}
[Fact]
public async Task HttpRequestEmptyResponseBodySkipsConversationAsync()
{
// Arrange - conversationId set, but empty body should not produce a conversation message.
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestEmptyResponseBodySkipsConversationAsync),
url: TestUrl,
method: HttpMethodType.Get,
conversationId: "conv-1");
MockHttpRequestHandler handler = new(HttpRequestResult(""));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()),
Times.Never);
}
[Fact]
public async Task HttpGetReturnsJsonArrayAsync()
{
// Arrange - exercises JsonValueKind.Array branch of ParseResponseBody.
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetReturnsJsonArrayAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult("[1, 2, 3]"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
FormulaValue stored = this.State.Get(ResponseVar);
Assert.IsType(stored, exactMatch: false);
}
[Fact]
public async Task HttpGetWithEmptyHeaderValueDropsHeaderAsync()
{
// Arrange - empty header values should be filtered out (matches GetHeaders guard).
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetWithEmptyHeaderValueDropsHeaderAsync),
url: TestUrl,
method: HttpMethodType.Get,
headers: new Dictionary
{
["X-Trace"] = "trace-1",
["X-Empty"] = "",
});
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Headers?.ContainsKey("X-Trace") == true &&
info.Headers?.ContainsKey("X-Empty") == false);
}
[Fact]
public async Task HttpRequestZeroTimeoutNotForwardedAsync()
{
// Arrange - non-positive timeouts should not be forwarded (handler default applies).
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestZeroTimeoutNotForwardedAsync),
url: TestUrl,
method: HttpMethodType.Get,
timeoutMilliseconds: 0);
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info => info.Timeout is null);
}
private static HttpRequestResult HttpRequestResult(
string? body,
int statusCode = 200,
bool isSuccess = true,
IReadOnlyDictionary>? headers = null) =>
new()
{
StatusCode = statusCode,
IsSuccessStatusCode = isSuccess,
Body = body,
Headers = headers,
};
private HttpRequestAction CreateModel(
string displayName,
string url,
HttpMethodType method,
string? responseVariable = null,
string? responseHeadersVariable = null,
IReadOnlyDictionary? headers = null,
IReadOnlyDictionary? queryParameters = null,
string? conversationId = null,
string? connectionName = null,
DataValue? jsonBody = null,
string? rawBody = null,
string? rawContentType = null,
long? timeoutMilliseconds = null,
string? continueOnErrorStatusVariable = null,
string? continueOnErrorBodyVariable = null)
{
HttpRequestAction.Builder builder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Url = new StringExpression.Builder(StringExpression.Literal(url)),
Method = new EnumExpression.Builder(
EnumExpression.Literal(HttpMethodTypeWrapper.Get(method))),
};
if (responseVariable is not null)
{
builder.Response = PropertyPath.Create(FormatVariablePath(responseVariable));
}
if (responseHeadersVariable is not null)
{
builder.ResponseHeaders = PropertyPath.Create(FormatVariablePath(responseHeadersVariable));
}
if (headers is not null)
{
foreach (KeyValuePair header in headers)
{
builder.Headers.Add(header.Key, new StringExpression.Builder(StringExpression.Literal(header.Value)));
}
}
if (queryParameters is not null)
{
foreach (KeyValuePair parameter in queryParameters)
{
builder.QueryParameters.Add(parameter.Key, new ValueExpression.Builder(ValueExpression.Literal(parameter.Value)));
}
}
if (conversationId is not null)
{
builder.ConversationId = new StringExpression.Builder(StringExpression.Literal(conversationId));
}
if (connectionName is not null)
{
builder.Connection = new RemoteConnection.Builder
{
Name = new StringExpression.Builder(StringExpression.Literal(connectionName)),
};
}
if (jsonBody is not null)
{
builder.Body = new JsonRequestContent.Builder()
{
Content = new ValueExpression.Builder(ValueExpression.Literal(jsonBody)),
};
}
else if (rawBody is not null)
{
RawRequestContent.Builder rawBuilder = new()
{
Content = new StringExpression.Builder(StringExpression.Literal(rawBody)),
};
if (rawContentType is not null)
{
rawBuilder.ContentType = new StringExpression.Builder(StringExpression.Literal(rawContentType));
}
builder.Body = rawBuilder;
}
if (timeoutMilliseconds is not null)
{
builder.RequestTimeoutInMilliseconds = new IntExpression.Builder(IntExpression.Literal(timeoutMilliseconds.Value));
}
if (continueOnErrorStatusVariable is not null || continueOnErrorBodyVariable is not null)
{
ContinueOnErrorBehavior.Builder continueBuilder = new();
if (continueOnErrorStatusVariable is not null)
{
continueBuilder.StatusCode = PropertyPath.Create(FormatVariablePath(continueOnErrorStatusVariable));
}
if (continueOnErrorBodyVariable is not null)
{
continueBuilder.ErrorResponseBody = PropertyPath.Create(FormatVariablePath(continueOnErrorBodyVariable));
}
builder.ErrorHandling = continueBuilder;
}
return AssignParent(builder);
}
private sealed class MockHttpRequestHandler : Mock
{
private HttpRequestInfo? _lastRequest;
public MockHttpRequestHandler(HttpRequestResult result, Exception? throwOnSend = null)
{
this.Setup(handler => handler.SendAsync(It.IsAny(), It.IsAny()))
.Returns((info, _) =>
{
this._lastRequest = info;
if (throwOnSend is not null)
{
throw throwOnSend;
}
return Task.FromResult(result);
});
}
public void VerifySent(Func predicate)
{
Assert.NotNull(this._lastRequest);
Assert.True(predicate(this._lastRequest!), "Sent HTTP request did not match expected predicate.");
}
}
}