From 9b16616fd7a56961c3ead12b39e6069ccd5ef685 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Fri, 24 Apr 2026 08:02:59 -0700 Subject: [PATCH 1/4] Add declarative HttpRequestAction support to workflows --- dotnet/agent-framework-dotnet.slnx | 6 +- .../DeclarativeWorkflowOptions.cs | 6 + .../DefaultHttpRequestHandler.cs | 241 ++++++ .../IHttpRequestHandler.cs | 103 +++ .../Interpreter/WorkflowActionVisitor.cs | 14 +- .../ObjectModel/HttpRequestExecutor.cs | 307 ++++++++ .../Framework/IntegrationTest.cs | 15 +- .../InvokeToolWorkflowTest.cs | 43 ++ .../Workflows/HttpRequest.yaml | 32 + .../DeclarativeWorkflowTest.cs | 24 +- .../DefaultHttpRequestHandlerTests.cs | 445 ++++++++++++ .../ObjectModel/HttpRequestExecutorTest.cs | 687 ++++++++++++++++++ .../Workflows/HttpRequest.yaml | 15 + 13 files changed, 1929 insertions(+), 9 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/HttpRequest.yaml create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/HttpRequest.yaml diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index b3a95ea1f6..47ca4e12ab 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -164,10 +164,10 @@ - + @@ -354,9 +354,9 @@ + - @@ -541,8 +541,8 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs index 9e421832d4..90439402db 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -26,6 +26,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid /// public IMcpToolHandler? McpToolHandler { get; init; } + /// + /// Gets or sets the HTTP request handler for executing HttpRequestAction actions within workflows. + /// If not set, HTTP request actions will fail with an appropriate error message. + /// + public IHttpRequestHandler? HttpRequestHandler { get; init; } + /// /// Defines the configuration settings for the workflow. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs new file mode 100644 index 0000000000..90bfba287b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows.Declarative; + +/// +/// Default implementation of built on . +/// +/// +/// +/// This handler supports per-request authentication via an optional httpClientProvider callback that +/// returns a pre-configured for a given request (e.g. authenticated, custom handler). +/// When the provider returns , or no provider is supplied, a shared internal +/// is used. +/// +/// +/// The handler applies the per-request using a linked +/// so it does not mutate on shared instances. +/// +/// +public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable +{ + private readonly Func>? _httpClientProvider; + private readonly Lazy _ownedHttpClient; + + /// + /// Initializes a new instance of the class. + /// + /// + /// An optional callback that provides an for each request. + /// The callback receives the and should return an + /// configured with any required authentication or transport. + /// Return to fall back to the handler's shared internal . + /// + public DefaultHttpRequestHandler(Func>? httpClientProvider = null) + { + this._httpClientProvider = httpClientProvider; + this._ownedHttpClient = new Lazy(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication); + } + + /// + public async Task SendAsync(HttpRequestInfo request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Url)) + { + throw new ArgumentException("Request URL must be provided.", nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Method)) + { + throw new ArgumentException("Request method must be provided.", nameof(request)); + } + + HttpClient? providedClient = null; + if (this._httpClientProvider is not null) + { + providedClient = await this._httpClientProvider(request, cancellationToken).ConfigureAwait(false); + } + + HttpClient client = providedClient ?? this._ownedHttpClient.Value; + + using HttpRequestMessage httpRequest = BuildHttpRequestMessage(request); + + using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + : null; + + timeoutCts?.CancelAfter(request.Timeout!.Value); + + CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken; + + using HttpResponseMessage httpResponse = await client + .SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken) + .ConfigureAwait(false); + + string? body = httpResponse.Content is null + ? null +#if NET + : await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false); +#else + : await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + + Dictionary> headers = new(StringComparer.OrdinalIgnoreCase); + AppendHeaders(headers, httpResponse.Headers); + if (httpResponse.Content is not null) + { + AppendHeaders(headers, httpResponse.Content.Headers); + } + + return new HttpRequestResult + { + StatusCode = (int)httpResponse.StatusCode, + IsSuccessStatusCode = httpResponse.IsSuccessStatusCode, + Body = body, + Headers = headers, + }; + } + + /// + public ValueTask DisposeAsync() + { + if (this._ownedHttpClient.IsValueCreated) + { + this._ownedHttpClient.Value.Dispose(); + } + + return default; + } + + private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo request) + { + HttpMethod method = ResolveMethod(request.Method); + string requestUri = ResolveRequestUri(request); + HttpRequestMessage httpRequest = new(method, requestUri); + + if (request.Body is not null) + { + string contentType = string.IsNullOrWhiteSpace(request.BodyContentType) + ? "text/plain" + : request.BodyContentType!; + + httpRequest.Content = new StringContent(request.Body, Encoding.UTF8); + // Replace the default content-type header (including charset) with the declared type. + httpRequest.Content.Headers.Remove("Content-Type"); + if (!httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", contentType)) + { + httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", "application/octet-stream"); + } + } + + if (request.Headers is not null) + { + foreach (KeyValuePair header in request.Headers) + { + if (string.IsNullOrEmpty(header.Key)) + { + continue; + } + + // Content-* headers belong on HttpContent; all others belong on the request. + if (header.Key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && httpRequest.Content is not null) + { + httpRequest.Content.Headers.Remove(header.Key); + httpRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + continue; + } + + if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value)) + { + httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + } + + return httpRequest; + } + + private static HttpMethod ResolveMethod(string method) => + method.Trim().ToUpperInvariant() switch + { + "GET" => HttpMethod.Get, + "POST" => HttpMethod.Post, + "PUT" => HttpMethod.Put, + "DELETE" => HttpMethod.Delete, +#if NET + "PATCH" => HttpMethod.Patch, +#else + "PATCH" => new HttpMethod("PATCH"), +#endif + _ => new HttpMethod(method), + }; + + private static string ResolveRequestUri(HttpRequestInfo request) + { + string baseUrl = request.Url; + if (request.QueryParameters is null || request.QueryParameters.Count == 0) + { + return baseUrl; + } + + StringBuilder queryBuilder = new(); + foreach (KeyValuePair parameter in request.QueryParameters) + { + if (string.IsNullOrEmpty(parameter.Key)) + { + continue; + } + + if (queryBuilder.Length > 0) + { + queryBuilder.Append('&'); + } + + queryBuilder.Append(Uri.EscapeDataString(parameter.Key)) + .Append('=') + .Append(Uri.EscapeDataString(parameter.Value ?? string.Empty)); + } + + if (queryBuilder.Length == 0) + { + return baseUrl; + } + + char separator = baseUrl.Contains('?') ? '&' : '?'; + return string.Concat(baseUrl, separator.ToString(), queryBuilder.ToString()); + } + + private static void AppendHeaders( + Dictionary> target, + System.Net.Http.Headers.HttpHeaders source) + { + foreach (KeyValuePair> header in source) + { + string[] values = header.Value.ToArray(); + + if (target.TryGetValue(header.Key, out IReadOnlyList? existing)) + { + List combined = new(existing); + combined.AddRange(values); + target[header.Key] = combined; + } + else + { + target[header.Key] = values; + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs new file mode 100644 index 0000000000..df80433d41 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows.Declarative; + +/// +/// Defines the contract for executing HTTP requests emitted by HttpRequestAction within declarative workflows. +/// +/// +/// This interface allows the HTTP request dispatch to be abstracted, enabling different implementations +/// for local development, hosted workflows, authenticated scenarios, and testing. +/// +public interface IHttpRequestHandler +{ + /// + /// Sends an HTTP request and returns the response. + /// + /// The HTTP request to send. + /// A token to observe cancellation. + /// The describing the HTTP response. + Task SendAsync( + HttpRequestInfo request, + CancellationToken cancellationToken = default); +} + +/// +/// Describes an HTTP request to be sent by an . +/// +[SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "URL is carried as a string to preserve the declarative expression result and to avoid forcing handler implementations to construct a Uri eagerly.")] +public sealed class HttpRequestInfo +{ + /// + /// Gets the HTTP method to use (GET, POST, PUT, PATCH, DELETE). + /// + public string Method { get; init; } = "GET"; + + /// + /// Gets the absolute URL to send the request to. + /// + public string Url { get; init; } = string.Empty; + + /// + /// Gets the headers to include on the request, excluding the Content-Type header (which is supplied via ). + /// + public IReadOnlyDictionary? Headers { get; init; } + + /// + /// Gets the Content-Type of the request body, or if no body is sent. + /// + public string? BodyContentType { get; init; } + + /// + /// Gets the serialized request body, or if no body is sent. + /// + public string? Body { get; init; } + + /// + /// Gets the maximum amount of time to wait for the request to complete, or to use the handler default. + /// + public TimeSpan? Timeout { get; init; } + + /// + /// Gets the query parameters to append to the request URL, with values already formatted as strings. + /// + public IReadOnlyDictionary? QueryParameters { get; init; } + + /// + /// Gets the name of the declared remote connection, or if no connection is declared. + /// This maps to the Foundry project connection Id and is only used when running in foundry service. + /// + public string? ConnectionName { get; init; } +} + +/// +/// Represents the result of an HTTP request executed by an . +/// +public sealed class HttpRequestResult +{ + /// + /// Gets the HTTP status code returned by the server. + /// + public int StatusCode { get; init; } + + /// + /// Gets a value indicating whether the status code is in the range 200-299. + /// + public bool IsSuccessStatusCode { get; init; } + + /// + /// Gets the response body, or if no body was returned. + /// + public string? Body { get; init; } + + /// + /// Gets the response headers keyed by header name. Each header may have multiple values. + /// + public IReadOnlyDictionary>? Headers { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index fd818672dd..1cd1b2bc94 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -529,6 +529,18 @@ protected override void Visit(InvokeMcpTool item) this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId); } + protected override void Visit(HttpRequestAction item) + { + this.Trace(item); + + if (this._workflowOptions.HttpRequestHandler is null) + { + throw new DeclarativeModelException("HTTP request handler not configured. Set HttpRequestHandler in DeclarativeWorkflowOptions to use HttpRequestAction actions."); + } + + this.ContinueWith(new HttpRequestExecutor(item, this._workflowOptions.HttpRequestHandler, this._workflowOptions.AgentProvider, this._workflowState)); + } + #region Not supported protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item); @@ -573,8 +585,6 @@ protected override void Visit(InvokeMcpTool item) protected override void Visit(GetConversationMembers item) => this.NotSupported(item); - protected override void Visit(HttpRequestAction item) => this.NotSupported(item); - protected override void Visit(RecognizeIntent item) => this.NotSupported(item); protected override void Visit(TransferConversation item) => this.NotSupported(item); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs new file mode 100644 index 0000000000..c4819d76b6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; + +/// +/// Executor for the action. +/// Dispatches the request through the configured and assigns +/// the response body and headers to the declared property paths. +/// +internal sealed class HttpRequestExecutor( + HttpRequestAction model, + IHttpRequestHandler httpRequestHandler, + ResponseAgentProvider agentProvider, + WorkflowFormulaState state) : + DeclarativeActionExecutor(model, state) +{ + /// + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + string method = this.GetMethod(); + string url = this.GetUrl(); + Dictionary? headers = this.GetHeaders(); + Dictionary? queryParameters = this.GetQueryParameters(); + (string? body, string? contentType) = this.GetBody(); + TimeSpan? timeout = this.GetTimeout(); + string? conversationId = this.GetConversationId(); + string? connectionName = this.GetConnectionName(); + + HttpRequestInfo requestInfo = new() + { + Method = method, + Url = url, + Headers = headers, + QueryParameters = queryParameters, + Body = body, + BodyContentType = contentType, + Timeout = timeout, + ConnectionName = connectionName, + }; + + HttpRequestResult result; + try + { + result = await httpRequestHandler.SendAsync(requestInfo, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw this.Exception($"HTTP request to '{url}' timed out."); + } + catch (Exception exception) when (exception is not DeclarativeActionException) + { + throw this.Exception($"HTTP request to '{url}' failed: {exception.Message}", exception); + } + + if (result.IsSuccessStatusCode) + { + await this.AssignResponseAsync(context, result.Body).ConfigureAwait(false); + await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false); + await this.AddResponseToConversationAsync(conversationId, result.Body, cancellationToken).ConfigureAwait(false); + return default; + } + + // Non-success status code - throw. + // Also publish response headers for diagnostic purposes. + await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false); + + throw this.Exception( + $"HTTP request to '{url}' failed with status code {result.StatusCode}. Body: '{result.Body}'"); + } + + private async ValueTask AddResponseToConversationAsync(string? conversationId, string? responseBody, CancellationToken cancellationToken) + { + if (conversationId is null || string.IsNullOrEmpty(responseBody)) + { + return; + } + + ChatMessage message = new(ChatRole.Assistant, responseBody); + await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask AssignResponseAsync(IWorkflowContext context, string? responseBody) + { + if (this.Model.Response is not { Path: { } responsePath }) + { + return; + } + + await this.AssignAsync(responsePath, ParseResponseBody(responseBody), context).ConfigureAwait(false); + } + + private async ValueTask AssignResponseHeadersAsync(IWorkflowContext context, IReadOnlyDictionary>? responseHeaders) + { + if (this.Model.ResponseHeaders is not { Path: { } headersPath }) + { + return; + } + + if (responseHeaders is null || responseHeaders.Count == 0) + { + await this.AssignAsync(headersPath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + return; + } + + // Flatten multi-value headers by joining with commas (standard HTTP header folding). + Dictionary flattened = new(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair> header in responseHeaders) + { + flattened[header.Key] = string.Join(",", header.Value); + } + + await this.AssignAsync(headersPath, flattened.ToFormula(), context).ConfigureAwait(false); + } + + private static FormulaValue ParseResponseBody(string? responseBody) + { + if (string.IsNullOrEmpty(responseBody)) + { + return FormulaValue.NewBlank(); + } + + // Attempt to parse as JSON so records/tables are exposed naturally to the workflow. + try + { + using JsonDocument jsonDocument = JsonDocument.Parse(responseBody); + + object? parsedValue = jsonDocument.RootElement.ValueKind switch + { + JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType), + JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()), + JsonValueKind.String => jsonDocument.RootElement.GetString(), + JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) + ? l + : jsonDocument.RootElement.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => responseBody, + }; + + return parsedValue.ToFormula(); + } + catch (JsonException) + { + // Not valid JSON — return the raw string. + return FormulaValue.New(responseBody); + } + } + + private string GetMethod() + { + EnumExpression? methodExpression = this.Model.Method; + if (methodExpression is null) + { + return "GET"; + } + + HttpMethodTypeWrapper wrapper = this.Evaluator.GetValue(methodExpression).Value; + return !string.IsNullOrEmpty(wrapper.UnknownValue) ? wrapper.UnknownValue! : wrapper.Value.ToString().ToUpperInvariant(); + } + + private string GetUrl() => + this.Evaluator.GetValue( + Throw.IfNull( + this.Model.Url, + $"{nameof(this.Model)}.{nameof(this.Model.Url)}")).Value; + + private Dictionary? GetHeaders() + { + if (this.Model.Headers is null || this.Model.Headers.Count == 0) + { + return null; + } + + Dictionary result = new(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair header in this.Model.Headers) + { + string value = this.Evaluator.GetValue(header.Value).Value; + if (!string.IsNullOrEmpty(value)) + { + result[header.Key] = value; + } + } + + return result.Count == 0 ? null : result; + } + + private (string? Body, string? ContentType) GetBody() + { + switch (this.Model.Body) + { + case null: + case NoRequestContent: + return (null, null); + + case JsonRequestContent jsonContent when jsonContent.Content is not null: + { + FormulaValue formula = this.Evaluator.GetValue(jsonContent.Content).Value.ToFormula(); + string json = formula.ToJson().ToJsonString(); + return (json, "application/json"); + } + + case RawRequestContent rawContent: + { + string? content = rawContent.Content is null + ? null + : this.Evaluator.GetValue(rawContent.Content).Value; + + string? contentType = rawContent.ContentType is null + ? null + : this.Evaluator.GetValue(rawContent.ContentType).Value; + + return (content, string.IsNullOrEmpty(contentType) ? null : contentType); + } + + default: + return (null, null); + } + } + + private TimeSpan? GetTimeout() + { + if (this.Model.RequestTimeoutInMilliseconds is null || this.Model.RequestTimeoutInMillisecondsIsDefaultValue) + { + return null; + } + + long value = this.Evaluator.GetValue(this.Model.RequestTimeoutInMilliseconds).Value; + return value > 0 ? TimeSpan.FromMilliseconds(value) : null; + } + + private Dictionary? GetQueryParameters() + { + if (this.Model.QueryParameters is null || this.Model.QueryParameters.Count == 0) + { + return null; + } + + Dictionary result = new(StringComparer.Ordinal); + foreach (KeyValuePair parameter in this.Model.QueryParameters) + { + if (string.IsNullOrEmpty(parameter.Key) || parameter.Value is null) + { + continue; + } + + object? rawValue = this.Evaluator.GetValue(parameter.Value).Value.ToObject(); + string? formatted = FormatQueryValue(rawValue); + if (formatted is not null) + { + result[parameter.Key] = formatted; + } + } + + return result.Count == 0 ? null : result; + } + + private static string? FormatQueryValue(object? value) => + value switch + { + null => null, + string s => s, + bool b => b ? "true" : "false", + IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture), + _ => value.ToString(), + }; + + private string? GetConversationId() + { + if (this.Model.ConversationId is null) + { + return null; + } + + string value = this.Evaluator.GetValue(this.Model.ConversationId).Value; + return value.Length == 0 ? null : value; + } + + private string? GetConnectionName() + { + RemoteConnection? connection = this.Model.Connection; + if (connection is null) + { + return null; + } + + string? name = connection.Name is null + ? null + : this.Evaluator.GetValue(connection.Name).Value; + + return string.IsNullOrEmpty(name) ? null : name; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs index 6be840ce48..61975aad23 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs @@ -60,10 +60,20 @@ protected static void SetProduct() protected async ValueTask CreateOptionsAsync(bool externalConversation = false, params IEnumerable functionTools) { - return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, functionTools).ConfigureAwait(false); + return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, httpRequestHandler: null, functionTools).ConfigureAwait(false); } protected async ValueTask CreateOptionsAsync(bool externalConversation, IMcpToolHandler? mcpToolProvider, params IEnumerable functionTools) + { + return await this.CreateOptionsAsync(externalConversation, mcpToolProvider, httpRequestHandler: null, functionTools).ConfigureAwait(false); + } + + protected async ValueTask CreateOptionsAsync(bool externalConversation, IHttpRequestHandler? httpRequestHandler, params IEnumerable functionTools) + { + return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, httpRequestHandler, functionTools).ConfigureAwait(false); + } + + protected async ValueTask CreateOptionsAsync(bool externalConversation, IMcpToolHandler? mcpToolProvider, IHttpRequestHandler? httpRequestHandler, params IEnumerable functionTools) { AzureAgentProvider agentProvider = new(this.TestEndpoint, TestAzureCliCredentials.CreateAzureCliCredential()) @@ -82,7 +92,8 @@ protected async ValueTask CreateOptionsAsync(bool ex { ConversationId = conversationId, LoggerFactory = this.Output, - McpToolHandler = mcpToolProvider + McpToolHandler = mcpToolProvider, + HttpRequestHandler = httpRequestHandler, }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs index bd5b250eb8..a247db6980 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs @@ -45,6 +45,15 @@ public Task ValidateInvokeMcpToolWithApprovalAsync(string workflowFileName, stri #endregion + #region InvokeHttpRequest Tests + + [Theory] + [InlineData("HttpRequest.yaml", "visibility: public")] + public Task ValidateHttpRequestAsync(string workflowFileName, string? expectedResultContains) => + this.RunHttpRequestTestAsync(workflowFileName, expectedResultContains); + + #endregion + #region InvokeFunctionTool Test Helpers /// @@ -250,6 +259,40 @@ private List ProcessMcpToolRequests( #endregion + #region InvokeHttpRequest Test Helpers + + /// + /// Runs an HttpRequestAction workflow test with the specified configuration. + /// + private async Task RunHttpRequestTestAsync( + string workflowFileName, + string? expectedResultContains = null) + { + // Arrange + string workflowPath = GetWorkflowPath(workflowFileName); + DefaultHttpRequestHandler httpRequestHandler = new(); + DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync( + externalConversation: false, + httpRequestHandler: httpRequestHandler); + + Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); + WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); + + // Act + WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("start").ConfigureAwait(false); + + // Assert - Verify executor and action events + AssertWorkflowEventsEmitted(workflowEvents); + + // Assert - Verify expected result if specified + if (expectedResultContains is not null) + { + AssertResultContains(workflowEvents, expectedResultContains); + } + } + + #endregion + #region Shared Helpers private static void AssertWorkflowEventsEmitted(WorkflowEvents workflowEvents) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/HttpRequest.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/HttpRequest.yaml new file mode 100644 index 0000000000..24ee0546e4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/HttpRequest.yaml @@ -0,0 +1,32 @@ +# +# This workflow tests invoking HttpRequestAction end-to-end. +# Uses the public GitHub API (unauthenticated) to fetch repo metadata. +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_http_request_test + actions: + + # Set the repo owner used to form the request URL. + - kind: SetVariable + id: set_repo_owner + variable: Local.RepoOwner + value: dotnet + + # Invoke the GitHub repo API. + - kind: HttpRequestAction + id: fetch_repo_info + conversationId: =System.ConversationId + method: GET + url: =Concatenate("https://api.github.com/repos/", Local.RepoOwner, "/runtime") + headers: + Accept: application/vnd.github+json + User-Agent: agent-framework-integration-test + response: Local.RepoInfo + + # Surface the Repo visibility field from the parsed JSON response. + - kind: SendMessage + id: show_visibility + message: "visibility: {Local.RepoInfo.visibility}" diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 6c61d6cb7d..d3e828fef6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -181,6 +181,7 @@ public async Task ConditionActionWithFallThroughAsync(int input, int expectedAct [InlineData("ResetVariable.yaml", 2, "clear_var")] [InlineData("MixedScopes.yaml", 2, "activity_input")] [InlineData("CaseInsensitive.yaml", 6, "end_when_match")] + [InlineData("HttpRequest.yaml", 1, "http_request")] public async Task ExecuteActionAsync(string workflowFile, int expectedCount, string expectedId) { await this.RunWorkflowAsync(workflowFile); @@ -200,7 +201,6 @@ public async Task ExecuteActionAsync(string workflowFile, int expectedCount, str [InlineData(typeof(EmitEvent.Builder))] [InlineData(typeof(GetActivityMembers.Builder))] [InlineData(typeof(GetConversationMembers.Builder))] - [InlineData(typeof(HttpRequestAction.Builder))] [InlineData(typeof(InvokeAIBuilderModelAction.Builder))] [InlineData(typeof(InvokeConnectorAction.Builder))] [InlineData(typeof(InvokeCustomModelAction.Builder))] @@ -266,6 +266,7 @@ public void UnsupportedAction(Type type) [InlineData("SendActivity.yaml", "activity_input")] [InlineData("SetVariable.yaml", "set_var")] [InlineData("SetTextVariable.yaml", "set_text")] + [InlineData("HttpRequest.yaml", "http_request")] public async Task CancelRunAsync(string workflowPath, string expectedExecutedId) { // Arrange @@ -374,7 +375,12 @@ private Workflow CreateWorkflow(string workflowPath, TInput workflowInpu { using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); Mock mockAgentProvider = CreateMockProvider($"{workflowInput}"); - DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output }; + DeclarativeWorkflowOptions workflowContext = + new(mockAgentProvider.Object) + { + LoggerFactory = this.Output, + HttpRequestHandler = CreateMockHttpRequestHandler().Object, + }; return DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); } @@ -385,4 +391,18 @@ private static Mock CreateMockProvider(string input) mockAgentProvider.Setup(provider => provider.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.FromResult(new ChatMessage(ChatRole.Assistant, input))); return mockAgentProvider; } + + private static Mock CreateMockHttpRequestHandler() + { + Mock mockHandler = new(MockBehavior.Loose); + mockHandler + .Setup(handler => handler.SendAsync(It.IsAny(), It.IsAny())) + .Returns(() => Task.FromResult(new HttpRequestResult + { + StatusCode = 200, + IsSuccessStatusCode = true, + Body = "{\"ok\":true}", + })); + return mockHandler; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs new file mode 100644 index 0000000000..0c7564f0cf --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -0,0 +1,445 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests; + +/// +/// Unit tests for . +/// +public sealed class DefaultHttpRequestHandlerTests +{ + private static readonly string[] s_setCookieValues = ["a=1", "b=2"]; + + private const string TestUrl = "https://api.example.test/resource"; + + #region Constructor Tests + + [Fact] + public async Task ConstructorWithNoParametersCreatesInstanceAsync() + { + // Act + await using DefaultHttpRequestHandler handler = new(); + + // Assert + handler.Should().NotBeNull(); + } + + [Fact] + public async Task ConstructorWithNullProviderCreatesInstanceAsync() + { + // Act + await using DefaultHttpRequestHandler handler = new(httpClientProvider: null); + + // Assert + handler.Should().NotBeNull(); + } + + #endregion + + #region Argument Validation Tests + + [Fact] + public async Task SendAsyncWithNullRequestThrowsAsync() + { + // Arrange + await using DefaultHttpRequestHandler handler = new(); + + // Act + Func act = async () => await handler.SendAsync(null!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SendAsyncWithEmptyUrlThrowsAsync() + { + // Arrange + await using DefaultHttpRequestHandler handler = new(); + HttpRequestInfo request = new() { Method = "GET", Url = "" }; + + // Act + Func act = async () => await handler.SendAsync(request); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SendAsyncWithEmptyMethodThrowsAsync() + { + // Arrange + await using DefaultHttpRequestHandler handler = new(); + HttpRequestInfo request = new() { Method = "", Url = TestUrl }; + + // Act + Func act = async () => await handler.SendAsync(request); + + // Assert + await act.Should().ThrowAsync(); + } + + #endregion + + #region Send Behavior Tests + + [Fact] + public async Task SendAsyncUsesProvidedHttpClientAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("hello", Encoding.UTF8, "text/plain"), + })); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() { Method = "GET", Url = TestUrl }; + + // Act + HttpRequestResult result = await handler.SendAsync(request); + + // Assert + messageHandler.LastRequest.Should().NotBeNull(); + messageHandler.LastRequest!.Method.Should().Be(HttpMethod.Get); + messageHandler.LastRequest.RequestUri!.ToString().Should().Be(TestUrl); + result.StatusCode.Should().Be(200); + result.IsSuccessStatusCode.Should().BeTrue(); + result.Body.Should().Be("hello"); + } + + [Fact] + public async Task SendAsyncMapsAllKnownMethodsAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + foreach (string method in new[] { "GET", "POST", "PUT", "PATCH", "DELETE", "CUSTOM" }) + { + HttpRequestInfo request = new() { Method = method, Url = TestUrl }; + + // Act + await handler.SendAsync(request); + + // Assert + messageHandler.LastRequest!.Method.Method.Should().Be(method); + } + } + + [Fact] + public async Task SendAsyncAppliesBodyAndContentTypeAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "{\"hello\":\"world\"}", + BodyContentType = "application/json", + }; + + // Act + await handler.SendAsync(request); + + // Assert + messageHandler.LastRequestBody.Should().Be("{\"hello\":\"world\"}"); + messageHandler.LastRequestContentType.Should().Be("application/json"); + } + + [Fact] + public async Task SendAsyncAppliesRequestHeadersAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + Headers = new Dictionary + { + ["Authorization"] = "Bearer secret", + ["Accept"] = "application/json", + }, + }; + + // Act + await handler.SendAsync(request); + + // Assert + messageHandler.LastRequest!.Headers.Authorization!.ToString().Should().Be("Bearer secret"); + messageHandler.LastRequest.Headers.Accept.Should().Contain(mediaType => mediaType.MediaType == "application/json"); + } + + [Fact] + public async Task SendAsyncRoutesContentHeadersToBodyAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "raw", + BodyContentType = "text/plain", + Headers = new Dictionary + { + ["Content-Language"] = "en-US", + }, + }; + + // Act + await handler.SendAsync(request); + + // Assert + messageHandler.LastRequest!.Content!.Headers.ContentLanguage.Should().Contain("en-US"); + } + + [Fact] + public async Task SendAsyncCapturesResponseHeadersAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + { +#pragma warning disable CA2025 + HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = new StringContent("ok", Encoding.UTF8, "text/plain"), + }; + response.Headers.Add("X-Request-Id", "request-1"); + response.Headers.Add("Set-Cookie", s_setCookieValues); + return Task.FromResult(response); +#pragma warning restore CA2025 + }); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() { Method = "GET", Url = TestUrl }; + + // Act + HttpRequestResult result = await handler.SendAsync(request); + + // Assert + result.Headers.Should().NotBeNull(); + result.Headers!.Should().ContainKey("X-Request-Id"); + result.Headers!["Set-Cookie"].Should().BeEquivalentTo(s_setCookieValues); + // Content headers also flattened in. + result.Headers!.Should().ContainKey("Content-Type"); + } + + [Fact] + public async Task SendAsyncReturnsFailureStatusWithoutThrowingAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("bad request", Encoding.UTF8, "text/plain"), + })); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() { Method = "GET", Url = TestUrl }; + + // Act + HttpRequestResult result = await handler.SendAsync(request); + + // Assert + result.IsSuccessStatusCode.Should().BeFalse(); + result.StatusCode.Should().Be(400); + result.Body.Should().Be("bad request"); + } + + [Fact] + public async Task SendAsyncTimeoutCancelsRequestAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new(async (req, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + Timeout = TimeSpan.FromMilliseconds(50), + }; + + // Act + Func act = async () => await handler.SendAsync(request); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SendAsyncFallsBackToOwnedClientWhenProviderReturnsNullAsync() + { + // Arrange + int providerCallCount = 0; + await using DefaultHttpRequestHandler handler = new((_, _) => + { + providerCallCount++; + return Task.FromResult(null); + }); + + HttpRequestInfo request = new() { Method = "GET", Url = "http://127.0.0.1:1/" }; + + // Act - owned client will attempt real network and fail, but provider path should have been consulted first. + Func act = async () => await handler.SendAsync(request); + + // Assert + await act.Should().ThrowAsync(); + providerCallCount.Should().Be(1); + } + + #endregion + + #region DisposeAsync + + [Fact] + public async Task DisposeAsyncCompletesAsync() + { + // Arrange + DefaultHttpRequestHandler handler = new(); + + // Act + Func act = async () => await handler.DisposeAsync(); + + // Assert + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task DisposeAsyncCalledMultipleTimesSucceedsAsync() + { + // Arrange + DefaultHttpRequestHandler handler = new(); + + // Act + await handler.DisposeAsync(); + Func second = async () => await handler.DisposeAsync(); + + // Assert + await second.Should().NotThrowAsync(); + } + + #endregion + + #region Query Parameters and Connection Tests + + [Fact] + public async Task QueryParametersAreAppendedToUrlAsync() + { + // Arrange + TestHttpMessageHandler fake = new(static (req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) })); + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(fake))); + + HttpRequestInfo info = new() + { + Method = "GET", + Url = TestUrl, + QueryParameters = new Dictionary + { + ["filter"] = "active items", + ["ids"] = "1,2,3", + }, + }; + + // Act + await handler.SendAsync(info); + + // Assert + fake.LastRequest.Should().NotBeNull(); + string? query = fake.LastRequest!.RequestUri!.Query; + query.Should().Contain("filter=active%20items"); + query.Should().Contain("ids=1%2C2%2C3"); + } + + [Fact] + public async Task QueryParametersPreserveExistingQueryStringAsync() + { + // Arrange + TestHttpMessageHandler fake = new(static (req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) })); + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(fake))); + + HttpRequestInfo info = new() + { + Method = "GET", + Url = TestUrl + "?existing=yes", + QueryParameters = new Dictionary + { + ["added"] = "true", + }, + }; + + // Act + await handler.SendAsync(info); + + // Assert + fake.LastRequest!.RequestUri!.Query.Should().Be("?existing=yes&added=true"); + } + + #endregion + + private sealed class TestHttpMessageHandler : HttpMessageHandler + { + private readonly Func> _responseFactory; + + public TestHttpMessageHandler(Func> responseFactory) + { + this._responseFactory = responseFactory; + } + + public HttpRequestMessage? LastRequest { get; private set; } + + public string? LastRequestBody { get; private set; } + + public string? LastRequestContentType { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastRequest = request; + if (request.Content is not null) + { +#if NET + this.LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + this.LastRequestContentType = request.Content.Headers.ContentType?.MediaType; + } + return await this._responseFactory(request, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs new file mode 100644 index 0000000000..f540e64d2a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs @@ -0,0 +1,687 @@ +// 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 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."); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/HttpRequest.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/HttpRequest.yaml new file mode 100644 index 0000000000..0eb89fe8d8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/HttpRequest.yaml @@ -0,0 +1,15 @@ +kind: Workflow +trigger: + + kind: OnConversationStart + id: my_workflow + actions: + + - kind: HttpRequestAction + id: http_request + method: GET + url: =Concatenate("https://api.example.test/items/", System.LastMessageText) + headers: + Accept: application/json + response: Local.HttpResult + responseHeaders: Local.HttpHeaders From ed9a9e01dd180fa8151867500f91c850c675395e Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Fri, 24 Apr 2026 11:55:39 -0700 Subject: [PATCH 2/4] Clean up response body for diagnostics and fix tests. --- .../DefaultHttpRequestHandler.cs | 69 +++++++++++++++--- .../ObjectModel/HttpRequestExecutor.cs | 43 ++++++++++- .../InvokeToolWorkflowTest.cs | 4 +- .../DefaultHttpRequestHandlerTests.cs | 65 +++++++++++++++++ .../ObjectModel/HttpRequestExecutorTest.cs | 72 +++++++++++++++++++ 5 files changed, 240 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 90bfba287b..90b1ec5be9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -31,20 +31,68 @@ public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDispo private readonly Lazy _ownedHttpClient; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class that uses an + /// internally owned for all requests. The internal client is disposed + /// when is called. + /// + public DefaultHttpRequestHandler() + : this(httpClientProvider: null) + { + } + + /// + /// Initializes a new instance of the class that uses the + /// supplied for all requests. + /// + /// + /// The to use for all requests. The caller retains ownership of this + /// instance; it is not disposed by . + /// + /// is . + public DefaultHttpRequestHandler(HttpClient httpClient) + : this(CreateSingleClientProvider(httpClient)) + { + } + + /// + /// Initializes a new instance of the class that selects + /// an per request via a caller-supplied callback — for example, to route + /// different URLs through differently authenticated clients. /// /// - /// An optional callback that provides an for each request. - /// The callback receives the and should return an - /// configured with any required authentication or transport. - /// Return to fall back to the handler's shared internal . + /// An optional callback invoked for each request. The callback receives the + /// and should return a pre-configured (e.g. with authentication or a custom + /// transport). Return to fall back to the handler's shared internal + /// . /// - public DefaultHttpRequestHandler(Func>? httpClientProvider = null) + /// + /// + /// Ownership: the caller is solely responsible for the lifetime of clients returned by this + /// callback. will not dispose provider-returned + /// clients; only the handler's internally owned fallback client is disposed by . + /// + /// + /// Reuse: callers are expected to cache and reuse clients (for example, keyed by base URL or + /// auth scope) across requests. Returning a newly allocated on every + /// invocation will leak sockets and handler resources. + /// + /// + public DefaultHttpRequestHandler(Func>? httpClientProvider) { this._httpClientProvider = httpClientProvider; this._ownedHttpClient = new Lazy(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication); } + private static Func> CreateSingleClientProvider(HttpClient httpClient) + { + if (httpClient is null) + { + throw new ArgumentNullException(nameof(httpClient)); + } + + return (_, _) => Task.FromResult(httpClient); + } + /// public async Task SendAsync(HttpRequestInfo request, CancellationToken cancellationToken = default) { @@ -168,8 +216,10 @@ private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo reques return httpRequest; } - private static HttpMethod ResolveMethod(string method) => - method.Trim().ToUpperInvariant() switch + private static HttpMethod ResolveMethod(string method) + { + string normalized = method.Trim().ToUpperInvariant(); + return normalized switch { "GET" => HttpMethod.Get, "POST" => HttpMethod.Post, @@ -180,8 +230,9 @@ private static HttpMethod ResolveMethod(string method) => #else "PATCH" => new HttpMethod("PATCH"), #endif - _ => new HttpMethod(method), + _ => new HttpMethod(normalized), }; + } private static string ResolveRequestUri(HttpRequestInfo request) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs index c4819d76b6..6bdddbf4e5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs @@ -78,8 +78,47 @@ internal sealed class HttpRequestExecutor( // Also publish response headers for diagnostic purposes. await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false); - throw this.Exception( - $"HTTP request to '{url}' failed with status code {result.StatusCode}. Body: '{result.Body}'"); + string bodyPreview = FormatBodyForDiagnostics(result.Body); + string message = bodyPreview.Length == 0 + ? $"HTTP request to '{url}' failed with status code {result.StatusCode}." + : $"HTTP request to '{url}' failed with status code {result.StatusCode}. Body: '{bodyPreview}'"; + + throw this.Exception(message); + } + + // Response bodies can echo secrets (tokens, PII) and may be very large (multi-MB HTML error pages). + // Exception messages are often logged and persisted, so we clip the body to bound both exposure + // and message size. Full bodies are still available via the success path (assigned to Response). + private const int MaxBodyDiagnosticLength = 256; + private const string BodyTruncationSuffix = " \u2026 [truncated]"; + + private static string FormatBodyForDiagnostics(string? body) + { + if (string.IsNullOrEmpty(body)) + { + return string.Empty; + } + + int sourceLen = body!.Length; + bool truncated = sourceLen > MaxBodyDiagnosticLength; + int copyLen = truncated ? MaxBodyDiagnosticLength : sourceLen; + int finalLen = copyLen + (truncated ? BodyTruncationSuffix.Length : 0); + + // Size the buffer for the final string so we only allocate once for the chars + // and once for the string itself. For a 10 KB error body we touch 256 chars instead of 10,000. + char[] buffer = new char[finalLen]; + for (int i = 0; i < copyLen; i++) + { + char c = body[i]; + buffer[i] = c is '\r' or '\n' or '\t' ? ' ' : c; + } + + if (truncated) + { + BodyTruncationSuffix.CopyTo(0, buffer, copyLen, BodyTruncationSuffix.Length); + } + + return new string(buffer); } private async ValueTask AddResponseToConversationAsync(string? conversationId, string? responseBody, CancellationToken cancellationToken) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs index a247db6980..ec09197376 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs @@ -47,7 +47,7 @@ public Task ValidateInvokeMcpToolWithApprovalAsync(string workflowFileName, stri #region InvokeHttpRequest Tests - [Theory] + [RetryTheory(3, 5000)] [InlineData("HttpRequest.yaml", "visibility: public")] public Task ValidateHttpRequestAsync(string workflowFileName, string? expectedResultContains) => this.RunHttpRequestTestAsync(workflowFileName, expectedResultContains); @@ -270,7 +270,7 @@ private async Task RunHttpRequestTestAsync( { // Arrange string workflowPath = GetWorkflowPath(workflowFileName); - DefaultHttpRequestHandler httpRequestHandler = new(); + await using DefaultHttpRequestHandler httpRequestHandler = new(); DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync( externalConversation: false, httpRequestHandler: httpRequestHandler); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 0c7564f0cf..e679a89ca3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -42,6 +42,55 @@ public async Task ConstructorWithNullProviderCreatesInstanceAsync() handler.Should().NotBeNull(); } + [Fact] + public void ConstructorWithNullHttpClientThrows() + { + // Act + Action act = () => _ = new DefaultHttpRequestHandler((HttpClient)null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public async Task ConstructorWithHttpClientUsesSuppliedClientForAllRequestsAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("ok", Encoding.UTF8, "text/plain"), + })); + using HttpClient suppliedClient = new(messageHandler); + await using DefaultHttpRequestHandler handler = new(suppliedClient); + HttpRequestInfo request = new() { Method = "GET", Url = TestUrl }; + + // Act + HttpRequestResult result = await handler.SendAsync(request); + + // Assert - the supplied HttpClient's underlying handler saw the request + messageHandler.LastRequest.Should().NotBeNull(); + messageHandler.LastRequest!.RequestUri!.ToString().Should().Be(TestUrl); + result.Body.Should().Be("ok"); + } + + [Fact] + public async Task DisposeAsyncDoesNotDisposeCallerSuppliedHttpClientAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + using HttpClient suppliedClient = new(messageHandler); + + // Act + DefaultHttpRequestHandler handler = new(suppliedClient); + await handler.DisposeAsync(); + + // Assert - supplied client remains usable (not disposed) + Func act = async () => await suppliedClient.GetAsync(new Uri(TestUrl)); + await act.Should().NotThrowAsync(); + } + #endregion #region Argument Validation Tests @@ -138,6 +187,22 @@ public async Task SendAsyncMapsAllKnownMethodsAsync() } } + [Fact] + public async Task SendAsyncNormalizesWhitespaceAroundCustomMethodAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + HttpRequestInfo request = new() { Method = " custom ", Url = TestUrl }; + + // Act + await handler.SendAsync(request); + + // Assert - fallback path should apply the same Trim/ToUpperInvariant normalization. + messageHandler.LastRequest!.Method.Method.Should().Be("CUSTOM"); + } + [Fact] public async Task SendAsyncAppliesBodyAndContentTypeAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs index f540e64d2a..bba8b4d64d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs @@ -216,6 +216,78 @@ public async Task HttpRequestRaisesOnErrorByDefaultAsync() 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() { From 1d659e2f8696b55720e09149340efc048210bc90 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Fri, 24 Apr 2026 13:19:06 -0700 Subject: [PATCH 3/4] Fix merge with main. --- dotnet/agent-framework-dotnet.slnx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index bb7ed9cf32..b9a5240fc9 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -163,10 +163,10 @@ + - @@ -355,9 +355,9 @@ - + @@ -543,8 +543,8 @@ - + From 3a3da7317cf89f4cfc876db0d944b05c474e8ed9 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Mon, 27 Apr 2026 10:42:15 -0700 Subject: [PATCH 4/4] Remove redundant fallback for request content headers. --- .../DefaultHttpRequestHandler.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 90b1ec5be9..606a716c20 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -183,10 +183,7 @@ private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo reques httpRequest.Content = new StringContent(request.Body, Encoding.UTF8); // Replace the default content-type header (including charset) with the declared type. httpRequest.Content.Headers.Remove("Content-Type"); - if (!httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", contentType)) - { - httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", "application/octet-stream"); - } + httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", contentType); } if (request.Headers is not null)