From 0b1ed03cd00c997cd668bb19a68776a5616a0fd2 Mon Sep 17 00:00:00 2001 From: alliscode Date: Tue, 31 Mar 2026 14:57:00 -0700 Subject: [PATCH] Add Azure AI Foundry Responses hosting adapter Implement Microsoft.Agents.AI.Hosting.AzureAIResponses to host agent-framework AIAgents and workflows within Azure Foundry as hosted agents via the Azure.AI.AgentServer.Responses SDK. - AgentFrameworkResponseHandler: bridges ResponseHandler to AIAgent execution - InputConverter: converts Responses API inputs/history to MEAI ChatMessage - OutputConverter: converts agent response updates to SSE event stream - ServiceCollectionExtensions: DI registration helpers - 336 unit tests across net8.0/net9.0/net10.0 (112 per TFM) - ResponseStreamValidator: SSE protocol validation tool for samples - FoundryResponsesHosting sample app Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 4 + dotnet/agent-framework-dotnet.slnx | 5 + dotnet/nuget.config | 4 + .../FoundryResponsesHosting.csproj | 27 + .../FoundryResponsesHosting/Pages.cs | 470 +++++++ .../FoundryResponsesHosting/Program.cs | 183 +++ .../Properties/launchSettings.json | 12 + .../ResponseStreamValidator.cs | 601 +++++++++ .../AgentFrameworkResponseHandler.cs | 186 +++ .../InputConverter.cs | 296 +++++ ....Agents.AI.Hosting.AzureAIResponses.csproj | 40 + .../OutputConverter.cs | 346 ++++++ .../ServiceCollectionExtensions.cs | 78 ++ .../AgentFrameworkResponseHandlerTests.cs | 815 +++++++++++++ .../InputConverterTests.cs | 671 ++++++++++ ....Hosting.AzureAIResponses.UnitTests.csproj | 17 + .../OutputConverterTests.cs | 1080 +++++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 72 ++ .../WorkflowIntegrationTests.cs | 509 ++++++++ 19 files changed, 5416 insertions(+) create mode 100644 dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj create mode 100644 dotnet/samples/04-hosting/FoundryResponsesHosting/Pages.cs create mode 100644 dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryResponsesHosting/Properties/launchSettings.json create mode 100644 dotnet/samples/04-hosting/FoundryResponsesHosting/ResponseStreamValidator.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/AgentFrameworkResponseHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/InputConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 1d3c2608b9..20dbf32bb6 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -19,9 +19,13 @@ + + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 24b596509e..3d0465763f 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -261,6 +261,9 @@ + + + @@ -491,6 +494,7 @@ + @@ -537,6 +541,7 @@ + diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 76d943ce16..202c1fc671 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -3,8 +3,12 @@ + + + + diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj b/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj new file mode 100644 index 0000000000..6725ef8d3b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj @@ -0,0 +1,27 @@ + + + + Exe + net10.0 + enable + enable + false + $(NoWarn);NU1903;NU1605 + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/Pages.cs b/dotnet/samples/04-hosting/FoundryResponsesHosting/Pages.cs new file mode 100644 index 0000000000..bff2c62e99 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/Pages.cs @@ -0,0 +1,470 @@ +// Copyright (c) Microsoft. All rights reserved. + +/// +/// Static HTML pages served by the sample application. +/// +internal static class Pages +{ + // ═══════════════════════════════════════════════════════════════════════ + // Homepage + // ═══════════════════════════════════════════════════════════════════════ + + internal const string Home = """ + + + + + Foundry Responses Hosting — Demos + + + +
+

🚀 Foundry Responses Hosting

+

+ Agent-framework agents hosted via the Azure AI Responses Server SDK.
+ Each demo registers a different agent and serves it through POST /responses. +

+ +
+ All demos share the same /responses endpoint. + The model field in the request selects which agent handles it. +
+
+ + +"""; + + // ═══════════════════════════════════════════════════════════════════════ + // Tool Demo + // ═══════════════════════════════════════════════════════════════════════ + + internal const string ToolDemo = """ + + + + + Tool Demo — Foundry Responses Hosting + + + +
+ ← Back to demos +

🔧 Tool Demo

+

Agent with local tools (time, weather) + Microsoft Learn MCP (docs search)

+
+ + + + +
+
+
+ + +
+
+
+ + + + +"""; + + // ═══════════════════════════════════════════════════════════════════════ + // Workflow Demo + // ═══════════════════════════════════════════════════════════════════════ + + internal const string WorkflowDemo = """ + + + + + Workflow Demo — Foundry Responses Hosting + + + +
+ ← Back to demos +

🔀 Workflow Demo — Agent Handoffs

+

A triage agent routes your question to a specialist (Code Expert or Creative Writer)

+
+
👤 User → 🔀 Triage → 💻 Code Expert / ✍️ Creative Writer
+
+
+ + + + +
+
+
+ + +
+
+
+ + + + +"""; + + // ═══════════════════════════════════════════════════════════════════════ + // SSE Validator Script (shared by all demo pages) + // ═══════════════════════════════════════════════════════════════════════ + + internal const string ValidationScript = """ +// SseValidator - inline SSE stream validation for Foundry Responses demos +// Captures events during streaming and validates against the API behaviour contract. +(function() { + const style = document.createElement('style'); + style.textContent = ` + .sse-val { margin: .4rem 0 .6rem; padding: .3rem .5rem; font-size: .75rem; color: #aaa; border-top: 1px dashed #e8e8e8; } + .val-ok { color: #7ab88a; } + .val-err { color: #d47272; font-weight: 500; } + .val-issues { margin: .2rem 0; } + .val-issue { color: #c06060; font-size: .72rem; padding: .1rem 0; } + .val-issue b { color: #b04040; } + .val-at { color: #ccc; font-size: .68rem; } + .val-log summary { cursor: pointer; color: #bbb; font-size: .72rem; } + .val-log-items { max-height: 120px; overflow-y: auto; font-size: .7rem; background: #fafafa; + padding: .3rem; border-radius: 3px; margin-top: .15rem; + font-family: 'Cascadia Code', 'Fira Code', monospace; } + .val-i { color: #ccc; display: inline-block; width: 1.8rem; text-align: right; margin-right: .3rem; } + .val-t { color: #8ab4d0; } + `; + document.head.appendChild(style); +})(); + +class SseValidator { + constructor() { this.events = []; } + reset() { this.events = []; } + capture(eventType, data) { this.events.push({ eventType, data }); } + + async validate() { + const resp = await fetch('/api/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ events: this.events }) + }); + return await resp.json(); + } + + renderElement(result) { + const el = document.createElement('div'); + el.className = 'sse-val'; + const n = result.eventCount; + const ok = result.isValid; + const vs = result.violations || []; + const esc = s => String(s).replace(/&/g,'&').replace(//g,'>'); + + let h = ok + ? `${n} events — all rules passed ✅` + : `${n} events — ${vs.length} violation(s)`; + + if (vs.length) { + h += '
'; + vs.forEach(v => { + h += `
[${esc(v.ruleId)}] ${esc(v.message)} #${v.eventIndex}
`; + }); + h += '
'; + } + + h += `
Event log (${this.events.length})
`; + this.events.forEach((e, i) => { + h += `
${i} ${esc(e.eventType)}
`; + }); + h += '
'; + + el.innerHTML = h; + return el; + } +} +"""; +} diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs b/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs new file mode 100644 index 0000000000..32f39f641c --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates hosting agent-framework agents as Foundry Hosted Agents +// using the Azure AI Responses Server SDK. +// +// Demos: +// / - Homepage listing all demos +// /tool-demo - Agent with local tools + remote MCP tools +// /workflow-demo - Triage workflow routing to specialist agents +// +// Prerequisites: +// - Azure OpenAI resource with a deployed model +// +// Environment variables: +// - AZURE_OPENAI_ENDPOINT - your Azure OpenAI endpoint +// - AZURE_OPENAI_DEPLOYMENT - the model deployment name (default: "gpt-4o") + +using System.ComponentModel; +using Azure.AI.OpenAI; +using Azure.AI.AgentServer.Responses; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.AzureAIResponses; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +var builder = WebApplication.CreateBuilder(args); + +// --------------------------------------------------------------------------- +// 1. Register the Azure AI Responses Server SDK +// --------------------------------------------------------------------------- +builder.Services.AddResponsesServer(); + +// --------------------------------------------------------------------------- +// 2. Create the shared Azure OpenAI chat client +// --------------------------------------------------------------------------- +var endpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.")); +var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o"; + +var azureClient = new AzureOpenAIClient(endpoint, new DefaultAzureCredential()); +IChatClient chatClient = azureClient.GetChatClient(deployment).AsIChatClient(); + +// --------------------------------------------------------------------------- +// 3. DEMO 1: Tool Agent — local tools + Microsoft Learn MCP +// --------------------------------------------------------------------------- +Console.WriteLine("Connecting to Microsoft Learn MCP server..."); +McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new() +{ + Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), + Name = "Microsoft Learn MCP", +})); +var mcpTools = await mcpClient.ListToolsAsync(); +Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}"); + +builder.AddAIAgent( + name: "tool-agent", + instructions: """ + You are a helpful assistant hosted as a Foundry Hosted Agent. + You have access to several tools - use them proactively: + - GetCurrentTime: Returns the current date/time in any timezone. + - GetWeather: Returns weather conditions for any location. + - Microsoft Learn MCP tools: Search and fetch Microsoft documentation. + When a user asks a technical question about Microsoft products, use the + documentation search tools to give accurate, up-to-date answers. + """, + chatClient: chatClient) + .WithAITool(AIFunctionFactory.Create(GetCurrentTime)) + .WithAITool(AIFunctionFactory.Create(GetWeather)) + .WithAITools(mcpTools.Cast().ToArray()); + +// --------------------------------------------------------------------------- +// 4. DEMO 2: Triage Workflow — routes to specialist agents +// --------------------------------------------------------------------------- +ChatClientAgent triageAgent = new( + chatClient, + instructions: """ + You are a triage agent that determines which specialist to hand off to. + Based on the user's question, ALWAYS hand off to one of the available agents. + Do NOT answer the question yourself - just route it. + """, + name: "triage_agent", + description: "Routes messages to the appropriate specialist agent"); + +ChatClientAgent codeExpert = new( + chatClient, + instructions: """ + You are a coding and technology expert. You help with programming questions, + explain technical concepts, debug code, and suggest best practices. + Provide clear, well-structured answers with code examples when appropriate. + """, + name: "code_expert", + description: "Specialist agent for programming and technology questions"); + +ChatClientAgent creativeWriter = new( + chatClient, + instructions: """ + You are a creative writing specialist. You help write stories, poems, + marketing copy, emails, and other creative content. You have a flair + for engaging language and vivid descriptions. + """, + name: "creative_writer", + description: "Specialist agent for creative writing and content tasks"); + +Workflow triageWorkflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triageAgent) + .WithHandoffs(triageAgent, [codeExpert, creativeWriter]) + .WithHandoffs([codeExpert, creativeWriter], triageAgent) + .Build(); + +builder.AddAIAgent("triage-workflow", (_, key) => + triageWorkflow.AsAIAgent(name: key)); + +// --------------------------------------------------------------------------- +// 5. Wire up the agent-framework handler as the IResponseHandler +// --------------------------------------------------------------------------- +builder.Services.AddAgentFrameworkHandler(); + +var app = builder.Build(); + +// Dispose the MCP client on shutdown +app.Lifetime.ApplicationStopping.Register(() => + mcpClient.DisposeAsync().AsTask().GetAwaiter().GetResult()); + +// --------------------------------------------------------------------------- +// 6. Routes +// --------------------------------------------------------------------------- +app.MapGet("/ready", () => Results.Ok("ready")); +app.MapResponsesServer(); + +app.MapGet("/", () => Results.Content(Pages.Home, "text/html")); +app.MapGet("/tool-demo", () => Results.Content(Pages.ToolDemo, "text/html")); +app.MapGet("/workflow-demo", () => Results.Content(Pages.WorkflowDemo, "text/html")); +app.MapGet("/js/sse-validator.js", () => Results.Content(Pages.ValidationScript, "application/javascript")); + +// Validation endpoint: accepts captured SSE lines and validates them +app.MapPost("/api/validate", (FoundryResponsesHosting.CapturedSseStream captured) => +{ + var validator = new FoundryResponsesHosting.ResponseStreamValidator(); + foreach (var evt in captured.Events) + { + validator.ProcessEvent(evt.EventType, evt.Data); + } + + validator.Complete(); + return Results.Json(validator.GetResult()); +}); + +app.Run(); + +// --------------------------------------------------------------------------- +// Local tool definitions +// --------------------------------------------------------------------------- + +[Description("Gets the current date and time in the specified timezone.")] +static string GetCurrentTime( + [Description("IANA timezone (e.g. 'America/New_York', 'Europe/London', 'UTC'). Defaults to UTC.")] + string timezone = "UTC") +{ + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(timezone); + return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).ToString("F"); + } + catch + { + return DateTime.UtcNow.ToString("F") + " (UTC - unknown timezone: " + timezone + ")"; + } +} + +[Description("Gets the current weather for a location. Returns temperature, conditions, and humidity.")] +static string GetWeather( + [Description("The city or location (e.g. 'Seattle', 'London, UK').")] + string location) +{ + // Simulated weather - deterministic per location for demo consistency + var rng = new Random(location.ToUpperInvariant().GetHashCode()); + var temp = rng.Next(-5, 35); + string[] conditions = ["sunny", "partly cloudy", "overcast", "rainy", "snowy", "windy", "foggy"]; + var condition = conditions[rng.Next(conditions.Length)]; + return $"Weather in {location}: {temp}C, {condition}. Humidity: {rng.Next(30, 90)}%. Wind: {rng.Next(5, 30)} km/h."; +} diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryResponsesHosting/Properties/launchSettings.json new file mode 100644 index 0000000000..b56d7a9ff4 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "FoundryResponsesHosting": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54747;http://localhost:54748" + } + } +} \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/ResponseStreamValidator.cs b/dotnet/samples/04-hosting/FoundryResponsesHosting/ResponseStreamValidator.cs new file mode 100644 index 0000000000..72da677f45 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/ResponseStreamValidator.cs @@ -0,0 +1,601 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FoundryResponsesHosting; + +/// Captured SSE event for validation. +[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Instantiated by JSON deserialization")] +internal sealed record CapturedSseEvent( + [property: JsonPropertyName("eventType")] string EventType, + [property: JsonPropertyName("data")] string Data); + +/// Captured SSE stream sent from the client for server-side validation. +[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Instantiated by JSON deserialization")] +internal sealed record CapturedSseStream( + [property: JsonPropertyName("events")] List Events); + +/// +/// Validates an SSE event stream from the Azure AI Responses Server SDK against +/// the API behaviour contract. Feed events sequentially via +/// and call when the stream ends. +/// +internal sealed class ResponseStreamValidator +{ + private readonly List _violations = []; + private int _eventCount; + private int _expectedSequenceNumber; + private StreamState _state = StreamState.Initial; + private string? _responseId; + private readonly HashSet _addedItemIndices = []; + private readonly HashSet _doneItemIndices = []; + private readonly HashSet _addedContentParts = []; // "outputIdx:partIdx" + private readonly HashSet _doneContentParts = []; + private readonly Dictionary _textAccumulators = []; // "outputIdx:contentIdx" → accumulated text + private bool _hasTerminal; + + /// All violations found so far. + internal IReadOnlyList Violations => _violations; + + /// + /// Processes a single SSE event line pair (event type + JSON data). + /// + /// The SSE event type (e.g. "response.created"). + /// The raw JSON data payload. + internal void ProcessEvent(string eventType, string jsonData) + { + JsonElement data; + try + { + data = JsonDocument.Parse(jsonData).RootElement; + } + catch (JsonException ex) + { + Fail("PARSE-01", $"Invalid JSON in event data: {ex.Message}"); + return; + } + + _eventCount++; + + // ── Sequence number validation ────────────────────────────────── + if (data.TryGetProperty("sequence_number", out var seqProp) && seqProp.ValueKind == JsonValueKind.Number) + { + int seq = seqProp.GetInt32(); + if (seq != _expectedSequenceNumber) + { + Fail("SEQ-01", $"Expected sequence_number {_expectedSequenceNumber}, got {seq}"); + } + + _expectedSequenceNumber = seq + 1; + } + else if (_state != StreamState.Initial || eventType != "error") + { + // Pre-creation error events may not have sequence_number + Fail("SEQ-02", $"Missing sequence_number on event '{eventType}'"); + } + + // ── Post-terminal guard ───────────────────────────────────────── + if (_hasTerminal) + { + Fail("TERM-01", $"Event '{eventType}' received after terminal event"); + return; + } + + // ── Dispatch by event type ────────────────────────────────────── + switch (eventType) + { + case "response.created": + ValidateResponseCreated(data); + break; + + case "response.queued": + ValidateStateTransition(eventType, StreamState.Created, StreamState.Queued); + ValidateResponseEnvelope(data, eventType); + break; + + case "response.in_progress": + if (_state is StreamState.Created or StreamState.Queued) + { + _state = StreamState.InProgress; + } + else + { + Fail("ORDER-02", $"'response.in_progress' received in state {_state} (expected Created or Queued)"); + } + + ValidateResponseEnvelope(data, eventType); + break; + + case "response.output_item.added": + case "output_item.added": + ValidateInProgress(eventType); + ValidateOutputItemAdded(data); + break; + + case "response.output_item.done": + case "output_item.done": + ValidateInProgress(eventType); + ValidateOutputItemDone(data); + break; + + case "response.content_part.added": + case "content_part.added": + ValidateInProgress(eventType); + ValidateContentPartAdded(data); + break; + + case "response.content_part.done": + case "content_part.done": + ValidateInProgress(eventType); + ValidateContentPartDone(data); + break; + + case "response.output_text.delta": + case "output_text.delta": + ValidateInProgress(eventType); + ValidateTextDelta(data); + break; + + case "response.output_text.done": + case "output_text.done": + ValidateInProgress(eventType); + ValidateTextDone(data); + break; + + case "response.function_call_arguments.delta": + case "function_call_arguments.delta": + ValidateInProgress(eventType); + break; + + case "response.function_call_arguments.done": + case "function_call_arguments.done": + ValidateInProgress(eventType); + break; + + case "response.completed": + ValidateTerminal(data, "completed"); + break; + + case "response.failed": + ValidateTerminal(data, "failed"); + break; + + case "response.incomplete": + ValidateTerminal(data, "incomplete"); + break; + + case "error": + // Pre-creation error — standalone, no response.created precedes it + if (_state != StreamState.Initial) + { + Fail("ERR-01", "'error' event received after response.created — should use response.failed instead"); + } + + _hasTerminal = true; + break; + + default: + // Unknown events are not violations — the spec may evolve + break; + } + } + + /// + /// Call after the stream ends. Checks that a terminal event was received. + /// + internal void Complete() + { + if (!_hasTerminal && _state != StreamState.Initial) + { + Fail("TERM-02", "Stream ended without a terminal event (response.completed, response.failed, or response.incomplete)"); + } + + if (_state == StreamState.Initial && _eventCount == 0) + { + Fail("EMPTY-01", "No events received in the stream"); + } + + // Check for output items that were added but never completed + foreach (int idx in _addedItemIndices) + { + if (!_doneItemIndices.Contains(idx)) + { + Fail("ITEM-03", $"Output item at index {idx} was added but never received output_item.done"); + } + } + + // Check for content parts that were added but never completed + foreach (string key in _addedContentParts) + { + if (!_doneContentParts.Contains(key)) + { + Fail("CONTENT-03", $"Content part '{key}' was added but never received content_part.done"); + } + } + } + + /// + /// Returns a summary of all validation results. + /// + internal ValidationResult GetResult() + { + return new ValidationResult( + EventCount: _eventCount, + IsValid: _violations.Count == 0, + Violations: [.. _violations]); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Event-specific validators + // ═══════════════════════════════════════════════════════════════════════ + + private void ValidateResponseCreated(JsonElement data) + { + if (_state != StreamState.Initial) + { + Fail("ORDER-01", $"'response.created' received in state {_state} (expected Initial — must be first event)"); + return; + } + + _state = StreamState.Created; + + // Must have a response envelope + if (!data.TryGetProperty("response", out var resp)) + { + Fail("FIELD-01", "'response.created' missing 'response' object"); + return; + } + + // Required response fields + ValidateRequiredResponseFields(resp, "response.created"); + + // Capture response ID for cross-event checks + if (resp.TryGetProperty("id", out var idProp)) + { + _responseId = idProp.GetString(); + } + + // Status must be non-terminal + if (resp.TryGetProperty("status", out var statusProp)) + { + string? status = statusProp.GetString(); + if (status is "completed" or "failed" or "incomplete" or "cancelled") + { + Fail("STATUS-01", $"'response.created' has terminal status '{status}' — must be 'queued' or 'in_progress'"); + } + } + } + + private void ValidateTerminal(JsonElement data, string expectedKind) + { + if (_state is StreamState.Initial or StreamState.Created) + { + Fail("ORDER-03", $"Terminal event 'response.{expectedKind}' received before 'response.in_progress'"); + } + + _hasTerminal = true; + _state = StreamState.Terminal; + + if (!data.TryGetProperty("response", out var resp)) + { + Fail("FIELD-01", $"'response.{expectedKind}' missing 'response' object"); + return; + } + + ValidateRequiredResponseFields(resp, $"response.{expectedKind}"); + + if (resp.TryGetProperty("status", out var statusProp)) + { + string? status = statusProp.GetString(); + + // completed_at validation (B6) + bool hasCompletedAt = resp.TryGetProperty("completed_at", out var catProp) + && catProp.ValueKind != JsonValueKind.Null; + + if (status == "completed" && !hasCompletedAt) + { + Fail("FIELD-02", "'completed_at' must be non-null when status is 'completed'"); + } + + if (status != "completed" && hasCompletedAt) + { + Fail("FIELD-03", $"'completed_at' must be null when status is '{status}'"); + } + + // error field validation + bool hasError = resp.TryGetProperty("error", out var errProp) + && errProp.ValueKind != JsonValueKind.Null; + + if (status == "failed" && !hasError) + { + Fail("FIELD-04", "'error' must be non-null when status is 'failed'"); + } + + if (status is "completed" or "incomplete" && hasError) + { + Fail("FIELD-05", $"'error' must be null when status is '{status}'"); + } + + // error structure validation + if (hasError) + { + ValidateErrorObject(errProp, $"response.{expectedKind}"); + } + + // cancelled output must be empty (B11) + if (status == "cancelled" && resp.TryGetProperty("output", out var outputProp) + && outputProp.ValueKind == JsonValueKind.Array && outputProp.GetArrayLength() > 0) + { + Fail("CANCEL-01", "Cancelled response must have empty output array (B11)"); + } + + // response ID consistency + if (_responseId is not null && resp.TryGetProperty("id", out var idProp) + && idProp.GetString() != _responseId) + { + Fail("ID-01", $"Response ID changed: was '{_responseId}', now '{idProp.GetString()}'"); + } + } + + // Usage validation (optional, but if present must be structured correctly) + if (resp.TryGetProperty("usage", out var usageProp) && usageProp.ValueKind == JsonValueKind.Object) + { + ValidateUsage(usageProp, $"response.{expectedKind}"); + } + } + + private void ValidateOutputItemAdded(JsonElement data) + { + if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number) + { + int index = idxProp.GetInt32(); + if (!_addedItemIndices.Add(index)) + { + Fail("ITEM-01", $"Duplicate output_item.added for output_index {index}"); + } + } + else + { + Fail("FIELD-06", "output_item.added missing 'output_index' field"); + } + + if (!data.TryGetProperty("item", out _)) + { + Fail("FIELD-07", "output_item.added missing 'item' object"); + } + } + + private void ValidateOutputItemDone(JsonElement data) + { + if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number) + { + int index = idxProp.GetInt32(); + if (!_addedItemIndices.Contains(index)) + { + Fail("ITEM-02", $"output_item.done for output_index {index} without preceding output_item.added"); + } + + _doneItemIndices.Add(index); + } + else + { + Fail("FIELD-06", "output_item.done missing 'output_index' field"); + } + } + + private void ValidateContentPartAdded(JsonElement data) + { + string key = GetContentPartKey(data); + if (!_addedContentParts.Add(key)) + { + Fail("CONTENT-01", $"Duplicate content_part.added for {key}"); + } + } + + private void ValidateContentPartDone(JsonElement data) + { + string key = GetContentPartKey(data); + if (!_addedContentParts.Contains(key)) + { + Fail("CONTENT-02", $"content_part.done for {key} without preceding content_part.added"); + } + + _doneContentParts.Add(key); + } + + private void ValidateTextDelta(JsonElement data) + { + string key = GetTextKey(data); + string delta = data.TryGetProperty("delta", out var deltaProp) + ? deltaProp.GetString() ?? string.Empty + : string.Empty; + + if (!_textAccumulators.TryGetValue(key, out string? existing)) + { + _textAccumulators[key] = delta; + } + else + { + _textAccumulators[key] = existing + delta; + } + } + + private void ValidateTextDone(JsonElement data) + { + string key = GetTextKey(data); + string? finalText = data.TryGetProperty("text", out var textProp) + ? textProp.GetString() + : null; + + if (finalText is null) + { + Fail("TEXT-01", $"output_text.done for {key} missing 'text' field"); + return; + } + + if (_textAccumulators.TryGetValue(key, out string? accumulated) && accumulated != finalText) + { + Fail("TEXT-02", $"output_text.done text for {key} does not match accumulated deltas (accumulated {accumulated.Length} chars, done has {finalText.Length} chars)"); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Shared field validators + // ═══════════════════════════════════════════════════════════════════════ + + private void ValidateRequiredResponseFields(JsonElement resp, string context) + { + if (!HasNonNullString(resp, "id")) + { + Fail("FIELD-01", $"{context}: response missing 'id'"); + } + + if (resp.TryGetProperty("object", out var objProp)) + { + if (objProp.GetString() != "response") + { + Fail("FIELD-08", $"{context}: response.object must be 'response', got '{objProp.GetString()}'"); + } + } + else + { + Fail("FIELD-08", $"{context}: response missing 'object' field"); + } + + if (!resp.TryGetProperty("created_at", out var catProp) || catProp.ValueKind == JsonValueKind.Null) + { + Fail("FIELD-09", $"{context}: response missing 'created_at'"); + } + + if (!resp.TryGetProperty("status", out _)) + { + Fail("FIELD-10", $"{context}: response missing 'status'"); + } + + if (!resp.TryGetProperty("output", out var outputProp) || outputProp.ValueKind != JsonValueKind.Array) + { + Fail("FIELD-11", $"{context}: response missing 'output' array"); + } + } + + private void ValidateErrorObject(JsonElement error, string context) + { + if (!HasNonNullString(error, "code")) + { + Fail("ERR-02", $"{context}: error object missing 'code' field"); + } + + if (!HasNonNullString(error, "message")) + { + Fail("ERR-03", $"{context}: error object missing 'message' field"); + } + } + + private void ValidateUsage(JsonElement usage, string context) + { + if (!usage.TryGetProperty("input_tokens", out _)) + { + Fail("USAGE-01", $"{context}: usage missing 'input_tokens'"); + } + + if (!usage.TryGetProperty("output_tokens", out _)) + { + Fail("USAGE-02", $"{context}: usage missing 'output_tokens'"); + } + + if (!usage.TryGetProperty("total_tokens", out _)) + { + Fail("USAGE-03", $"{context}: usage missing 'total_tokens'"); + } + } + + private void ValidateResponseEnvelope(JsonElement data, string eventType) + { + if (!data.TryGetProperty("response", out var resp)) + { + Fail("FIELD-01", $"'{eventType}' missing 'response' object"); + return; + } + + ValidateRequiredResponseFields(resp, eventType); + + // Response ID consistency + if (_responseId is not null && resp.TryGetProperty("id", out var idProp) + && idProp.GetString() != _responseId) + { + Fail("ID-01", $"Response ID changed: was '{_responseId}', now '{idProp.GetString()}'"); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Helpers + // ═══════════════════════════════════════════════════════════════════════ + + private void ValidateInProgress(string eventType) + { + if (_state != StreamState.InProgress) + { + Fail("ORDER-04", $"'{eventType}' received in state {_state} (expected InProgress)"); + } + } + + private void ValidateStateTransition(string eventType, StreamState expected, StreamState next) + { + if (_state != expected) + { + Fail("ORDER-05", $"'{eventType}' received in state {_state} (expected {expected})"); + } + else + { + _state = next; + } + } + + private void Fail(string ruleId, string message) + { + _violations.Add(new ValidationViolation(ruleId, message, _eventCount)); + } + + private static bool HasNonNullString(JsonElement obj, string property) + { + return obj.TryGetProperty(property, out var prop) + && prop.ValueKind == JsonValueKind.String + && !string.IsNullOrEmpty(prop.GetString()); + } + + private static string GetContentPartKey(JsonElement data) + { + int outputIdx = data.TryGetProperty("output_index", out var oi) ? oi.GetInt32() : -1; + int partIdx = data.TryGetProperty("content_index", out var pi) ? pi.GetInt32() : -1; + return $"{outputIdx}:{partIdx}"; + } + + private static string GetTextKey(JsonElement data) + { + int outputIdx = data.TryGetProperty("output_index", out var oi) ? oi.GetInt32() : -1; + int contentIdx = data.TryGetProperty("content_index", out var ci) ? ci.GetInt32() : -1; + return $"{outputIdx}:{contentIdx}"; + } + + private enum StreamState + { + Initial, + Created, + Queued, + InProgress, + Terminal, + } +} + +/// A single validation violation. +/// The rule identifier (e.g. SEQ-01, FIELD-02). +/// Human-readable description of the violation. +/// 1-based index of the event that triggered this violation. +internal sealed record ValidationViolation(string RuleId, string Message, int EventIndex); + +/// Overall validation result. +/// Total number of events processed. +/// True if no violations were found. +/// List of all violations. +internal sealed record ValidationResult(int EventCount, bool IsValid, IReadOnlyList Violations); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/AgentFrameworkResponseHandler.cs new file mode 100644 index 0000000000..5f07cd4530 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/AgentFrameworkResponseHandler.cs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses; + +/// +/// A implementation that bridges the Azure AI Responses Server SDK +/// with agent-framework instances, enabling agent-framework agents and workflows +/// to be hosted as Azure Foundry Hosted Agents. +/// +public class AgentFrameworkResponseHandler : ResponseHandler +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class + /// that resolves agents from keyed DI services. + /// + /// The service provider for resolving agents. + /// The logger instance. + public AgentFrameworkResponseHandler( + IServiceProvider serviceProvider, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(logger); + + this._serviceProvider = serviceProvider; + this._logger = logger; + } + + /// + public override async IAsyncEnumerable CreateAsync( + CreateResponse request, + ResponseContext context, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + // 1. Resolve agent + var agent = this.ResolveAgent(request); + + // 2. Create the SDK event stream builder + var stream = new ResponseEventStream(context, request); + + // 3. Emit lifecycle events + yield return stream.EmitCreated(); + yield return stream.EmitInProgress(); + + // 4. Convert input: history + current input → ChatMessage[] + var messages = new List(); + + // Load conversation history if available + var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); + if (history.Count > 0) + { + messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history)); + } + + // Load and convert current input items + var inputItems = await context.GetInputItemsAsync(cancellationToken).ConfigureAwait(false); + if (inputItems.Count > 0) + { + messages.AddRange(InputConverter.ConvertOutputItemsToMessages(inputItems)); + } + else + { + // Fall back to raw request input + messages.AddRange(InputConverter.ConvertInputToMessages(request)); + } + + // 5. Build chat options + var chatOptions = InputConverter.ConvertToChatOptions(request); + chatOptions.Instructions = request.Instructions; + var options = new ChatClientAgentRunOptions(chatOptions); + + // 6. Run the agent and convert output + // NOTE: C# forbids 'yield return' inside a try block that has a catch clause, + // and inside catch blocks. We use a flag to defer the yield to outside the try/catch. + bool emittedTerminal = false; + var enumerator = OutputConverter.ConvertUpdatesToEventsAsync( + agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken), + stream, + cancellationToken).GetAsyncEnumerator(cancellationToken); + try + { + while (true) + { + bool shutdownDetected = false; + ResponseStreamEvent? evt = null; + try + { + if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + break; + } + + evt = enumerator.Current; + } + catch (OperationCanceledException) when (context.IsShutdownRequested && !emittedTerminal) + { + shutdownDetected = true; + } + + if (shutdownDetected) + { + // Server is shutting down — emit incomplete so clients can resume + this._logger.LogInformation("Shutdown detected, emitting incomplete response."); + yield return stream.EmitIncomplete(); + yield break; + } + + // yield is in the outer try (finally-only) — allowed by C# + yield return evt!; + + if (evt is ResponseCompletedEvent or ResponseFailedEvent or ResponseIncompleteEvent) + { + emittedTerminal = true; + } + } + } + finally + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + } + + /// + /// Resolves an from the request. + /// Tries agent.name first, then falls back to metadata["entity_id"]. + /// If neither is present, attempts to resolve a default (non-keyed) . + /// + private AIAgent ResolveAgent(CreateResponse request) + { + var agentName = GetAgentName(request); + + if (!string.IsNullOrEmpty(agentName)) + { + var agent = this._serviceProvider.GetKeyedService(agentName); + if (agent is not null) + { + return agent; + } + + this._logger.LogWarning("Agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName); + } + + // Try non-keyed default + var defaultAgent = this._serviceProvider.GetService(); + if (defaultAgent is not null) + { + return defaultAgent; + } + + var errorMessage = string.IsNullOrEmpty(agentName) + ? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered." + : $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AIAgent."; + + throw new InvalidOperationException(errorMessage); + } + + private static string? GetAgentName(CreateResponse request) + { + // Try agent.name from AgentReference + var agentName = request.AgentReference?.Name; + + // Fall back to "model" field (OpenAI clients send the agent name as the model) + if (string.IsNullOrEmpty(agentName)) + { + agentName = request.Model; + } + + // Fall back to metadata["entity_id"] + if (string.IsNullOrEmpty(agentName) && request.Metadata?.AdditionalProperties is not null) + { + request.Metadata.AdditionalProperties.TryGetValue("entity_id", out agentName); + } + + return agentName; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/InputConverter.cs new file mode 100644 index 0000000000..a35c8cd5b8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/InputConverter.cs @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses; + +/// +/// Converts Responses Server SDK input types to agent-framework types. +/// +internal static class InputConverter +{ + /// + /// Converts the SDK request input items into a list of . + /// + /// The create response request from the SDK. + /// A list of chat messages representing the request input. + public static List ConvertInputToMessages(CreateResponse request) + { + var messages = new List(); + + foreach (var item in request.GetInputExpanded()) + { + var message = ConvertInputItemToMessage(item); + if (message is not null) + { + messages.Add(message); + } + } + + return messages; + } + + /// + /// Converts resolved SDK history/input items into instances. + /// + /// The resolved output items from the SDK context. + /// A list of chat messages. + public static List ConvertOutputItemsToMessages(IReadOnlyList items) + { + var messages = new List(); + + foreach (var item in items) + { + var message = ConvertOutputItemToMessage(item); + if (message is not null) + { + messages.Add(message); + } + } + + return messages; + } + + /// + /// Creates from the SDK request properties. + /// + /// The create response request. + /// A configured instance. + public static ChatOptions ConvertToChatOptions(CreateResponse request) + { + return new ChatOptions + { + Temperature = (float?)request.Temperature, + TopP = (float?)request.TopP, + MaxOutputTokens = (int?)request.MaxOutputTokens, + ModelId = request.Model, + }; + } + + private static ChatMessage? ConvertInputItemToMessage(Item item) + { + return item switch + { + ItemMessage msg => ConvertItemMessage(msg), + FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput), + ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall), + ItemReferenceParam => null, + _ => null + }; + } + + private static ChatMessage ConvertItemMessage(ItemMessage msg) + { + var role = ConvertMessageRole(msg.Role); + var contents = new List(); + + foreach (var content in msg.GetContentExpanded()) + { + switch (content) + { + case MessageContentInputTextContent textContent: + contents.Add(new MeaiTextContent(textContent.Text)); + break; + case MessageContentInputImageContent imageContent: + if (imageContent.ImageUrl is not null) + { + var url = imageContent.ImageUrl.ToString(); + if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + contents.Add(new DataContent(url, "image/*")); + } + else + { + contents.Add(new UriContent(imageContent.ImageUrl, "image/*")); + } + } + else if (!string.IsNullOrEmpty(imageContent.FileId)) + { + contents.Add(new HostedFileContent(imageContent.FileId)); + } + + break; + case MessageContentInputFileContent fileContent: + if (fileContent.FileUrl is not null) + { + contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream")); + } + else if (!string.IsNullOrEmpty(fileContent.FileData)) + { + contents.Add(new DataContent(fileContent.FileData, "application/octet-stream")); + } + else if (!string.IsNullOrEmpty(fileContent.FileId)) + { + contents.Add(new HostedFileContent(fileContent.FileId)); + } + else if (!string.IsNullOrEmpty(fileContent.Filename)) + { + contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]")); + } + + break; + } + } + + if (contents.Count == 0) + { + contents.Add(new MeaiTextContent(string.Empty)); + } + + return new ChatMessage(role, contents); + } + + private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput) + { + var output = funcOutput.Output?.ToString() ?? string.Empty; + return new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(funcOutput.CallId, output)]); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK input.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK input.")] + private static ChatMessage ConvertItemFunctionToolCall(ItemFunctionToolCall funcCall) + { + IDictionary? arguments = null; + if (funcCall.Arguments is not null) + { + try + { + arguments = JsonSerializer.Deserialize>(funcCall.Arguments); + } + catch (JsonException) + { + arguments = new Dictionary { ["_raw"] = funcCall.Arguments }; + } + } + + return new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]); + } + + private static ChatMessage? ConvertOutputItemToMessage(OutputItem item) + { + return item switch + { + OutputItemMessage msg => ConvertOutputItemMessageToChat(msg), + OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall), + FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput), + OutputItemReasoningItem => null, + _ => null + }; + } + + private static ChatMessage ConvertOutputItemMessageToChat(OutputItemMessage msg) + { + var role = ConvertMessageRole(msg.Role); + var contents = new List(); + + foreach (var content in msg.Content) + { + switch (content) + { + case MessageContentInputTextContent textContent: + contents.Add(new MeaiTextContent(textContent.Text)); + break; + case MessageContentOutputTextContent textContent: + contents.Add(new MeaiTextContent(textContent.Text)); + break; + case MessageContentRefusalContent refusal: + contents.Add(new MeaiTextContent($"[Refusal: {refusal.Refusal}]")); + break; + case MessageContentInputImageContent imageContent: + if (imageContent.ImageUrl is not null) + { + var url = imageContent.ImageUrl.ToString(); + if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + contents.Add(new DataContent(url, "image/*")); + } + else + { + contents.Add(new UriContent(imageContent.ImageUrl, "image/*")); + } + } + else if (!string.IsNullOrEmpty(imageContent.FileId)) + { + contents.Add(new HostedFileContent(imageContent.FileId)); + } + + break; + case MessageContentInputFileContent fileContent: + if (fileContent.FileUrl is not null) + { + contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream")); + } + else if (!string.IsNullOrEmpty(fileContent.FileData)) + { + contents.Add(new DataContent(fileContent.FileData, "application/octet-stream")); + } + else if (!string.IsNullOrEmpty(fileContent.FileId)) + { + contents.Add(new HostedFileContent(fileContent.FileId)); + } + else if (!string.IsNullOrEmpty(fileContent.Filename)) + { + contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]")); + } + + break; + } + } + + if (contents.Count == 0) + { + contents.Add(new MeaiTextContent(string.Empty)); + } + + return new ChatMessage(role, contents); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK output history.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK output history.")] + private static ChatMessage ConvertOutputItemFunctionCall(OutputItemFunctionToolCall funcCall) + { + IDictionary? arguments = null; + if (funcCall.Arguments is not null) + { + try + { + arguments = JsonSerializer.Deserialize>(funcCall.Arguments); + } + catch (JsonException) + { + arguments = new Dictionary { ["_raw"] = funcCall.Arguments }; + } + } + + return new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]); + } + + private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput) + { + return new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]); + } + + private static ChatRole ConvertMessageRole(MessageRole role) + { + return role switch + { + MessageRole.User => ChatRole.User, + MessageRole.Assistant => ChatRole.Assistant, + MessageRole.System => ChatRole.System, + MessageRole.Developer => new ChatRole("developer"), + _ => ChatRole.User + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj new file mode 100644 index 0000000000..b881e287cc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj @@ -0,0 +1,40 @@ + + + + $(TargetFrameworksCore) + enable + Microsoft.Agents.AI.Hosting.AzureAIResponses + alpha + $(NoWarn);MEAI001;NU1903 + false + + + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs new file mode 100644 index 0000000000..c620cf6324 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses; + +/// +/// Converts agent-framework streams into +/// Responses Server SDK sequences using the +/// builder pattern. +/// +internal static class OutputConverter +{ + /// + /// Converts a stream of into a stream of + /// using the SDK builder pattern. + /// + /// The agent response updates to convert. + /// The SDK event stream builder. + /// Cancellation token. + /// An async enumerable of SDK response stream events (excluding lifecycle events). + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call arguments dictionary.")] + public static async IAsyncEnumerable ConvertUpdatesToEventsAsync( + IAsyncEnumerable updates, + ResponseEventStream stream, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ResponseUsage? accumulatedUsage = null; + OutputItemMessageBuilder? currentMessageBuilder = null; + TextContentBuilder? currentTextBuilder = null; + StringBuilder? accumulatedText = null; + string? previousMessageId = null; + bool hasTerminalEvent = false; + var executorItemIds = new Dictionary(); + + await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Handle workflow events from RawRepresentation + if (update.RawRepresentation is WorkflowEvent workflowEvent) + { + // Close any open message builder before emitting workflow items + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + previousMessageId = null; + + foreach (var evt in EmitWorkflowEvent(stream, workflowEvent, executorItemIds)) + { + yield return evt; + } + + continue; + } + + foreach (var content in update.Contents) + { + switch (content) + { + case MeaiTextContent textContent: + { + if (!IsSameMessage(update.MessageId, previousMessageId) && currentMessageBuilder is not null) + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + } + + previousMessageId = update.MessageId; + + if (currentMessageBuilder is null) + { + currentMessageBuilder = stream.AddOutputItemMessage(); + yield return currentMessageBuilder.EmitAdded(); + + currentTextBuilder = currentMessageBuilder.AddTextContent(); + yield return currentTextBuilder.EmitAdded(); + + accumulatedText = new StringBuilder(); + } + + if (textContent.Text is { Length: > 0 }) + { + accumulatedText!.Append(textContent.Text); + yield return currentTextBuilder!.EmitDelta(textContent.Text); + } + + break; + } + + case FunctionCallContent funcCall: + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + previousMessageId = null; + + var callId = funcCall.CallId ?? Guid.NewGuid().ToString("N"); + var funcBuilder = stream.AddOutputItemFunctionCall(funcCall.Name, callId); + yield return funcBuilder.EmitAdded(); + + var arguments = funcCall.Arguments is not null + ? JsonSerializer.Serialize(funcCall.Arguments) + : "{}"; + + yield return funcBuilder.EmitArgumentsDelta(arguments); + yield return funcBuilder.EmitArgumentsDone(arguments); + yield return funcBuilder.EmitDone(); + break; + } + + case TextReasoningContent reasoningContent: + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + previousMessageId = null; + + var reasoningBuilder = stream.AddOutputItemReasoningItem(); + yield return reasoningBuilder.EmitAdded(); + + var summaryPart = reasoningBuilder.AddSummaryPart(); + yield return summaryPart.EmitAdded(); + + var text = reasoningContent.Text ?? string.Empty; + yield return summaryPart.EmitTextDelta(text); + yield return summaryPart.EmitTextDone(text); + yield return summaryPart.EmitDone(); + reasoningBuilder.EmitSummaryPartDone(summaryPart); + + yield return reasoningBuilder.EmitDone(); + break; + } + + case UsageContent usageContent when usageContent.Details is not null: + { + accumulatedUsage = ConvertUsage(usageContent.Details, accumulatedUsage); + break; + } + + case ErrorContent errorContent: + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + previousMessageId = null; + hasTerminalEvent = true; + + yield return stream.EmitFailed( + ResponseErrorCode.ServerError, + errorContent.Message ?? "An error occurred during agent execution.", + accumulatedUsage); + yield break; + } + + case DataContent: + case UriContent: + // Image/audio/file content from agents is not currently supported + // as streaming output items in the Responses Server SDK builder pattern. + // These would need to be serialized as base64 or URL references. + break; + + case FunctionResultContent: + // Function results are internal to the agent's tool-calling loop + // and are not emitted as output items in the response stream. + break; + + default: + break; + } + } + } + + // Close any remaining open message + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + if (!hasTerminalEvent) + { + yield return stream.EmitCompleted(accumulatedUsage); + } + } + + private static IEnumerable CloseCurrentMessage( + OutputItemMessageBuilder? messageBuilder, + TextContentBuilder? textBuilder, + StringBuilder? accumulatedText) + { + if (messageBuilder is null) + { + yield break; + } + + if (textBuilder is not null) + { + var finalText = accumulatedText?.ToString() ?? string.Empty; + yield return textBuilder.EmitDone(finalText); + yield return messageBuilder.EmitContentDone(textBuilder); + } + + yield return messageBuilder.EmitDone(); + } + + private static bool IsSameMessage(string? currentId, string? previousId) => + currentId is not { Length: > 0 } || previousId is not { Length: > 0 } || currentId == previousId; + + private static ResponseUsage ConvertUsage(UsageDetails details, ResponseUsage? existing) + { + var inputTokens = (long)(details.InputTokenCount ?? 0); + var outputTokens = (long)(details.OutputTokenCount ?? 0); + var totalTokens = (long)(details.TotalTokenCount ?? 0); + + if (existing is not null) + { + inputTokens += existing.InputTokens; + outputTokens += existing.OutputTokens; + totalTokens += existing.TotalTokens; + } + + return AzureAIAgentServerResponsesModelFactory.ResponseUsage( + inputTokens: inputTokens, + outputTokens: outputTokens, + totalTokens: totalTokens); + } + + private static IEnumerable EmitWorkflowEvent( + ResponseEventStream stream, + WorkflowEvent workflowEvent, + Dictionary executorItemIds) + { + switch (workflowEvent) + { + case ExecutorInvokedEvent invokedEvent: + { + var itemId = GenerateItemId("wfa"); + executorItemIds[invokedEvent.ExecutorId] = itemId; + + var item = new WorkflowActionOutputItem( + kind: "InvokeExecutor", + actionId: invokedEvent.ExecutorId, + status: WorkflowActionOutputItemStatus.InProgress, + id: itemId); + + var builder = stream.AddOutputItem(itemId); + yield return builder.EmitAdded(item); + yield return builder.EmitDone(item); + break; + } + + case ExecutorCompletedEvent completedEvent: + { + var itemId = GenerateItemId("wfa"); + + var item = new WorkflowActionOutputItem( + kind: "InvokeExecutor", + actionId: completedEvent.ExecutorId, + status: WorkflowActionOutputItemStatus.Completed, + id: itemId); + + var builder = stream.AddOutputItem(itemId); + yield return builder.EmitAdded(item); + yield return builder.EmitDone(item); + executorItemIds.Remove(completedEvent.ExecutorId); + break; + } + + case ExecutorFailedEvent failedEvent: + { + var itemId = GenerateItemId("wfa"); + + var item = new WorkflowActionOutputItem( + kind: "InvokeExecutor", + actionId: failedEvent.ExecutorId, + status: WorkflowActionOutputItemStatus.Failed, + id: itemId); + + var builder = stream.AddOutputItem(itemId); + yield return builder.EmitAdded(item); + yield return builder.EmitDone(item); + executorItemIds.Remove(failedEvent.ExecutorId); + break; + } + + // Informational/lifecycle events — no SDK output needed. + // Note: AgentResponseUpdateEvent and WorkflowErrorEvent are unwrapped by + // WorkflowSession.InvokeStageAsync() into regular AgentResponseUpdate objects + // with populated Contents (TextContent, ErrorContent, etc.), so they flow + // through the normal content processing path above — not through this method. + case SuperStepStartedEvent: + case SuperStepCompletedEvent: + case WorkflowStartedEvent: + case WorkflowWarningEvent: + case RequestInfoEvent: + break; + } + } + + /// + /// Generates a valid item ID matching the SDK's {prefix}_{50chars} format. + /// + private static string GenerateItemId(string prefix) + { + // SDK format: {prefix}_{50 char body} + var bytes = RandomNumberGenerator.GetBytes(25); + var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase + return $"{prefix}_{body}"; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..d596ba3457 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.AgentServer.Responses; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses; + +/// +/// Extension methods for to register the agent-framework +/// response handler with the Azure AI Responses Server SDK. +/// +public static class AgentFrameworkResponsesServiceCollectionExtensions +{ + /// + /// Registers as the + /// for the Azure AI Responses Server SDK. Agents are resolved from keyed DI services + /// using the agent.name or metadata["entity_id"] from incoming requests. + /// + /// + /// + /// Call this method after AddResponsesServer() and after registering your + /// instances (e.g., via AddAIAgent()). + /// + /// + /// Example: + /// + /// builder.Services.AddResponsesServer(); + /// builder.AddAIAgent("my-agent", ...); + /// builder.Services.AddAgentFrameworkHandler(); + /// + /// var app = builder.Build(); + /// app.MapResponsesServer(); + /// + /// + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddAgentFrameworkHandler(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddSingleton(); + return services; + } + + /// + /// Registers a specific as the handler for all incoming requests, + /// regardless of the agent.name in the request. + /// + /// + /// + /// Use this overload when hosting a single agent. The provided agent instance is + /// registered both as a keyed service and as the default . + /// + /// + /// Example: + /// + /// builder.Services.AddResponsesServer(); + /// builder.Services.AddAgentFrameworkHandler(myAgent); + /// + /// var app = builder.Build(); + /// app.MapResponsesServer(); + /// + /// + /// + /// The service collection. + /// The agent instance to register. + /// The service collection for chaining. + public static IServiceCollection AddAgentFrameworkHandler(this IServiceCollection services, AIAgent agent) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(agent); + + services.TryAddSingleton(agent); + services.TryAddSingleton(); + return services; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs new file mode 100644 index 0000000000..ee32771a67 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -0,0 +1,815 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; + +public class AgentFrameworkResponseHandlerTests +{ + private static string ValidResponseId => "resp_" + new string('0', 46); + + [Fact] + public async Task CreateAsync_WithDefaultAgent_ProducesStreamEvents() + { + // Arrange + var agent = CreateTestAgent("Hello from the agent!"); + var services = new ServiceCollection(); + services.AddSingleton(agent); + services.AddSingleton>(NullLogger.Instance); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}"); + Assert.IsType(events[0]); + Assert.IsType(events[1]); + } + + [Fact] + public async Task CreateAsync_WithKeyedAgent_ResolvesCorrectAgent() + { + // Arrange + var agent = CreateTestAgent("Keyed agent response"); + var services = new ServiceCollection(); + services.AddKeyedSingleton("my-agent", agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("my-agent")); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert - should have produced events from the keyed agent + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_NoAgentRegistered_ThrowsInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + } + }); + } + + [Fact] + public void Constructor_NullServiceProvider_ThrowsArgumentNullException() + { + Assert.Throws( + () => new AgentFrameworkResponseHandler(null!, NullLogger.Instance)); + } + + [Fact] + public void Constructor_NullLogger_ThrowsArgumentNullException() + { + var sp = new ServiceCollection().BuildServiceProvider(); + Assert.Throws( + () => new AgentFrameworkResponseHandler(sp, null!)); + } + + [Fact] + public async Task CreateAsync_ResolvesAgentByModelField() + { + // Arrange + var agent = CreateTestAgent("model agent"); + var services = new ServiceCollection(); + services.AddKeyedSingleton("my-agent", agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-agent"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_ResolvesAgentByEntityIdMetadata() + { + // Arrange + var agent = CreateTestAgent("entity agent"); + var services = new ServiceCollection(); + services.AddKeyedSingleton("entity-agent", agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: ""); + var metadata = new Metadata(); + metadata.AdditionalProperties["entity_id"] = "entity-agent"; + request.Metadata = metadata; + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_NamedAgentNotFound_FallsBackToDefault() + { + // Arrange + var agent = CreateTestAgent("default agent"); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("nonexistent-agent")); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_NoAgentFound_ErrorMessageIncludesAgentName() + { + // Arrange + var services = new ServiceCollection(); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("missing-agent")); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act & Assert + var ex = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + } + }); + + Assert.Contains("missing-agent", ex.Message); + } + + [Fact] + public async Task CreateAsync_NoAgentNoName_ErrorMessageIsGeneric() + { + // Arrange + var services = new ServiceCollection(); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: ""); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act & Assert + var ex = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + } + }); + + Assert.Contains("No agent name specified", ex.Message); + } + + [Fact] + public async Task CreateAsync_AgentResolvedBeforeEmitCreated_ExceptionHasNoEvents() + { + // Arrange + var services = new ServiceCollection(); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + bool threw = false; + try + { + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + } + catch (InvalidOperationException) + { + threw = true; + } + + // Assert + Assert.True(threw); + Assert.Empty(events); + } + + [Fact] + public async Task CreateAsync_WithHistory_PrependsHistoryToMessages() + { + // Arrange + var agent = new CapturingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var historyItem = new OutputItemMessage( + id: "hist_1", + role: MessageRole.Assistant, + content: [new MessageContentOutputTextContent( + "Previous response", + Array.Empty(), + Array.Empty())], + status: MessageStatus.Completed); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(new OutputItem[] { historyItem }); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.NotNull(agent.CapturedMessages); + var messages = agent.CapturedMessages.ToList(); + Assert.True(messages.Count >= 2); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + } + + [Fact] + public async Task CreateAsync_WithInputItems_UsesResolvedInputItems() + { + // Arrange + var agent = new CapturingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Raw input" } } } + }); + + var inputItem = new OutputItemMessage( + id: "input_1", + role: MessageRole.Assistant, + content: [new MessageContentOutputTextContent( + "Resolved input", + Array.Empty(), + Array.Empty())], + status: MessageStatus.Completed); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(new OutputItem[] { inputItem }); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.NotNull(agent.CapturedMessages); + var messages = agent.CapturedMessages.ToList(); + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + } + + [Fact] + public async Task CreateAsync_NoInputItems_FallsBackToRawRequestInput() + { + // Arrange + var agent = new CapturingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Raw input" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.NotNull(agent.CapturedMessages); + var messages = agent.CapturedMessages.ToList(); + Assert.Single(messages); + Assert.Equal(ChatRole.User, messages[0].Role); + } + + [Fact] + public async Task CreateAsync_PassesInstructionsToAgent() + { + // Arrange + var agent = new CapturingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + instructions: "You are a helpful assistant."); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.NotNull(agent.CapturedOptions); + var chatClientOptions = Assert.IsType(agent.CapturedOptions); + Assert.Equal("You are a helpful assistant.", chatClientOptions.ChatOptions?.Instructions); + } + + [Fact] + public async Task CreateAsync_AgentThrows_ExceptionPropagates() + { + // Arrange + var agent = new ThrowingAgent(new InvalidOperationException("Agent crashed")); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + } + }); + } + + [Fact] + public async Task CreateAsync_MultipleKeyedAgents_ResolvesCorrectOne() + { + // Arrange + var agent1 = CreateTestAgent("Agent 1 response"); + var agent2 = CreateTestAgent("Agent 2 response"); + var services = new ServiceCollection(); + services.AddKeyedSingleton("agent-1", agent1); + services.AddKeyedSingleton("agent-2", agent2); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("agent-2")); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_CancellationDuringExecution_PropagatesOperationCanceledException() + { + // Arrange + var agent = new CancellationCheckingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, cts.Token)) + { + } + }); + } + + private static TestAgent CreateTestAgent(string responseText) + { + return new TestAgent(responseText); + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(params AgentResponseUpdate[] items) + { + foreach (var item in items) + { + yield return item; + } + + await Task.CompletedTask; + } + + private sealed class TestAgent(string responseText) : AIAgent + { + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + ToAsyncEnumerableAsync(new AgentResponseUpdate + { + MessageId = "resp_msg_1", + Contents = [new MeaiTextContent(responseText)] + }); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + private sealed class ThrowingAgent(Exception exception) : AIAgent + { + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw exception; + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + private sealed class CapturingAgent : AIAgent + { + public IEnumerable? CapturedMessages { get; private set; } + public AgentRunOptions? CapturedOptions { get; private set; } + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) + { + CapturedMessages = messages.ToList(); + CapturedOptions = options; + return ToAsyncEnumerableAsync(new AgentResponseUpdate + { + MessageId = "resp_msg_1", + Contents = [new MeaiTextContent("captured")] + }); + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + private sealed class CancellationCheckingAgent : AIAgent + { + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new AgentResponseUpdate { Contents = [new MeaiTextContent("test")] }; + await Task.CompletedTask; + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs new file mode 100644 index 0000000000..34555798a7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs @@ -0,0 +1,671 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; + +public class InputConverterTests +{ + [Fact] + public void ConvertInputToMessages_EmptyRequest_ReturnsEmptyList() + { + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(Array.Empty()); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Empty(messages); + } + + [Fact] + public void ConvertInputToMessages_UserTextMessage_ReturnsUserMessage() + { + var input = new[] + { + new + { + type = "message", + id = "msg_001", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Hello, agent!" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(ChatRole.User, messages[0].Role); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text == "Hello, agent!"); + } + + [Fact] + public void ConvertInputToMessages_FunctionCallOutput_ReturnsToolMessage() + { + var input = new[] + { + new + { + type = "function_call_output", + id = "fc_out_001", + call_id = "call_123", + output = "42" + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(ChatRole.Tool, messages[0].Role); + var funcResult = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(funcResult); + Assert.Equal("call_123", funcResult.CallId); + } + + [Fact] + public void ConvertInputToMessages_FunctionToolCall_ReturnsAssistantMessage() + { + var input = new[] + { + new + { + type = "function_call", + id = "fc_001", + call_id = "call_456", + name = "get_weather", + arguments = "{\"location\": \"Seattle\"}" + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + var funcCall = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(funcCall); + Assert.Equal("call_456", funcCall.CallId); + Assert.Equal("get_weather", funcCall.Name); + } + + [Fact] + public void ConvertInputToMessages_MultipleItems_ReturnsAllMessages() + { + var input = new object[] + { + new + { + type = "message", + id = "msg_001", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "What's the weather?" } } + }, + new + { + type = "function_call", + id = "fc_001", + call_id = "call_789", + name = "get_weather", + arguments = "{}" + }, + new + { + type = "function_call_output", + id = "fc_out_001", + call_id = "call_789", + output = "Sunny, 72°F" + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Equal(3, messages.Count); + Assert.Equal(ChatRole.User, messages[0].Role); + Assert.Equal(ChatRole.Assistant, messages[1].Role); + Assert.Equal(ChatRole.Tool, messages[2].Role); + } + + [Fact] + public void ConvertToChatOptions_SetsTemperatureAndTopP() + { + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 1000, + model: "gpt-4o"); + + var options = InputConverter.ConvertToChatOptions(request); + + Assert.Equal(0.7f, options.Temperature); + Assert.Equal(0.9f, options.TopP); + Assert.Equal(1000, options.MaxOutputTokens); + Assert.Equal("gpt-4o", options.ModelId); + } + + [Fact] + public void ConvertToChatOptions_NullValues_SetsNulls() + { + var request = new CreateResponse(); + + var options = InputConverter.ConvertToChatOptions(request); + + Assert.Null(options.Temperature); + Assert.Null(options.TopP); + Assert.Null(options.MaxOutputTokens); + } + + [Fact] + public void ConvertOutputItemsToMessages_OutputMessage_ReturnsAssistantMessage() + { + var textContent = new MessageContentOutputTextContent( + "Hello from assistant", + Array.Empty(), + Array.Empty()); + var outputMsg = new OutputItemMessage( + id: "out_001", + role: MessageRole.Assistant, + content: [textContent], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text == "Hello from assistant"); + } + + [Fact] + public void ConvertOutputItemsToMessages_FunctionToolCall_ReturnsAssistantMessage() + { + var funcCall = new OutputItemFunctionToolCall( + callId: "call_abc", + name: "search", + arguments: "{\"query\": \"test\"}"); + + var messages = InputConverter.ConvertOutputItemsToMessages([funcCall]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + var content = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(content); + Assert.Equal("call_abc", content.CallId); + Assert.Equal("search", content.Name); + } + + [Fact] + public void ConvertOutputItemsToMessages_FunctionToolCallOutputResource_ReturnsToolMessage() + { + var funcOutput = new FunctionToolCallOutputResource( + callId: "call_def", + output: BinaryData.FromString("result data")); + + var messages = InputConverter.ConvertOutputItemsToMessages([funcOutput]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Tool, messages[0].Role); + var result = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(result); + Assert.Equal("call_def", result.CallId); + } + + [Fact] + public void ConvertOutputItemsToMessages_ReasoningItem_ReturnsNull() + { + var reasoning = AzureAIAgentServerResponsesModelFactory.OutputItemReasoningItem( + id: "reason_001"); + + var messages = InputConverter.ConvertOutputItemsToMessages([reasoning]); + + Assert.Empty(messages); + } + + // ── Image Content Tests (B-03 through B-06) ── + + [Fact] + public void ConvertInputToMessages_ImageContentWithHttpUrl_ReturnsUriContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_image", image_url = "https://example.com/img.png" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is UriContent); + } + + [Fact] + public void ConvertInputToMessages_ImageContentWithDataUri_ReturnsDataContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_image", image_url = "data:image/png;base64,iVBORw0KGgo=" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is DataContent); + } + + [Fact] + public void ConvertInputToMessages_ImageContentWithFileId_ReturnsHostedFileContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_image", file_id = "file_abc123" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is HostedFileContent); + } + + [Fact] + public void ConvertInputToMessages_ImageContentNoUrlOrFileId_ProducesNoContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_image" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Single(messages[0].Contents); + } + + // ── File Content Tests (B-07 through B-11) ── + + [Fact] + public void ConvertInputToMessages_FileContentWithUrl_ReturnsUriContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file", file_url = "https://example.com/doc.pdf" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is UriContent); + } + + [Fact] + public void ConvertInputToMessages_FileContentWithInlineData_ReturnsDataContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file", file_data = "data:application/pdf;base64,iVBORw0KGgo=" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is DataContent); + } + + [Fact] + public void ConvertInputToMessages_FileContentWithFileId_ReturnsHostedFileContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file", file_id = "file_xyz789" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is HostedFileContent); + } + + [Fact] + public void ConvertInputToMessages_FileContentWithFilenameOnly_ReturnsFallbackText() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file", filename = "report.pdf" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("report.pdf")); + } + + [Fact] + public void ConvertInputToMessages_FileContentWithNothing_ProducesNoContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Single(messages[0].Contents); + } + + // ── Mixed Content / Edge Cases (B-15 through B-18) ── + + [Fact] + public void ConvertInputToMessages_MixedContentInSingleMessage_ReturnsAllContentTypes() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new object[] + { + new { type = "input_text", text = "Look at this:" }, + new { type = "input_image", image_url = "https://example.com/img.png" } + } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(2, messages[0].Contents.Count); + } + + [Fact] + public void ConvertInputToMessages_EmptyMessageContent_ReturnsFallbackTextContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = Array.Empty() + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + var textContent = Assert.IsType(Assert.Single(messages[0].Contents)); + Assert.Equal(string.Empty, textContent.Text); + } + + [Fact] + public void ConvertOutputItemsToMessages_OutputMessageRefusal_ReturnsRefusalText() + { + var refusal = new MessageContentRefusalContent("I cannot help with that"); + var outputMsg = new OutputItemMessage( + id: "out_1", + role: MessageRole.Assistant, + content: [refusal], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("[Refusal:")); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("I cannot help with that")); + } + + [Fact] + public void ConvertInputToMessages_ItemReferenceParam_IsSkipped() + { + var input = new object[] + { + new { type = "item_reference", id = "ref_001" }, + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + } + + // ── Role Mapping Tests (C-01 through C-05) ── + + [Fact] + public void ConvertInputToMessages_UserRole_ReturnsChatRoleUser() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Hi" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(ChatRole.User, messages[0].Role); + } + + [Fact] + public void ConvertOutputItemsToMessages_AssistantRole_ReturnsChatRoleAssistant() + { + // OutputItemMessage always maps to assistant role + var textContent = new MessageContentOutputTextContent( + "Hi", Array.Empty(), Array.Empty()); + var outputMsg = new OutputItemMessage( + id: "msg_1", + role: MessageRole.Assistant, + content: [textContent], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + } + + // ── History Conversion Edge Cases (D-02 through D-12) ── + + [Fact] + public void ConvertOutputItemsToMessages_OutputMessageWithRefusal_ReturnsRefusalText() + { + var refusal = new MessageContentRefusalContent("Not allowed"); + var outputMsg = new OutputItemMessage( + id: "out_1", + role: MessageRole.Assistant, + content: [refusal], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("[Refusal:")); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("Not allowed")); + } + + [Fact] + public void ConvertOutputItemsToMessages_OutputMessageWithEmptyContent_ReturnsFallbackText() + { + var outputMsg = new OutputItemMessage( + id: "out_1", + role: MessageRole.Assistant, + content: [], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + var textContent = Assert.IsType(Assert.Single(messages[0].Contents)); + Assert.Equal(string.Empty, textContent.Text); + } + + [Fact] + public void ConvertOutputItemsToMessages_FunctionToolCallWithMalformedArgs_UsesRawFallback() + { + var funcCall = new OutputItemFunctionToolCall( + callId: "call_1", + name: "test", + arguments: "not-json{{{"); + + var messages = InputConverter.ConvertOutputItemsToMessages([funcCall]); + + Assert.Single(messages); + var content = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(content); + Assert.NotNull(content.Arguments); + Assert.True(content.Arguments.ContainsKey("_raw")); + } + + [Fact] + public void ConvertOutputItemsToMessages_UnknownOutputItemType_IsSkipped() + { + var messages = InputConverter.ConvertOutputItemsToMessages([]); + + Assert.Empty(messages); + } + + [Fact] + public void ConvertToChatOptions_ModelId_SetFromRequest() + { + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model"); + + var options = InputConverter.ConvertToChatOptions(request); + + Assert.Equal("my-model", options.ModelId); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj new file mode 100644 index 0000000000..09c3ba24c1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj @@ -0,0 +1,17 @@ + + + + $(TargetFrameworksCore) + false + $(NoWarn);NU1903;NU1605 + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs new file mode 100644 index 0000000000..dde11298c7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs @@ -0,0 +1,1080 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using Microsoft.Agents.AI.Workflows; +using Moq; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; + +public class OutputConverterTests +{ + private static (ResponseEventStream stream, Mock mockContext) CreateTestStream() + { + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model"); + var stream = new ResponseEventStream(mockContext.Object, request); + return (stream, mockContext); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_EmptyStream_EmitsCompleted() + { + var (stream, _) = CreateTestStream(); + var updates = ToAsync(Array.Empty()); + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream)) + { + events.Add(evt); + } + + Assert.Single(events); + Assert.IsType(events[0]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_SingleTextUpdate_EmitsMessageAndCompleted() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("Hello, world!")] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + // Expected: MessageAdded, TextAdded, TextDelta, TextDone, ContentDone, MessageDone, Completed + Assert.True(events.Count >= 5, $"Expected at least 5 events, got {events.Count}"); + Assert.IsType(events[0]); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultipleTextUpdates_EmitsStreamingDeltas() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Hello, ")] }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("world!")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Should have two text delta events among the others + Assert.True(events.Count >= 6, $"Expected at least 6 events, got {events.Count}"); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCall_EmitsFunctionCallEvents() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_1", "get_weather", + new Dictionary { ["city"] = "Seattle" })] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + // Should have: FuncAdded, ArgsDelta, ArgsDone, FuncDone, Completed + Assert.IsType(events[0]); + Assert.IsType(events[^1]); + Assert.True(events.Count >= 4, $"Expected at least 4 events for function call, got {events.Count}"); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorContent_EmitsFailed() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new ErrorContent("Something went wrong")] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.IsType(events[^1]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorContent_DoesNotEmitCompleted() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new ErrorContent("Failure")] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.DoesNotContain(events, e => e is ResponseCompletedEvent); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_UsageContent_IncludesUsageInCompleted() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("Hi")] + }, + new AgentResponseUpdate + { + Contents = [new UsageContent(new UsageDetails + { + InputTokenCount = 10, + OutputTokenCount = 5, + TotalTokenCount = 15 + })] + } + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + var completedEvent = events.OfType().SingleOrDefault(); + Assert.NotNull(completedEvent); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReasoningContent_EmitsReasoningEvents() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new TextReasoningContent("Let me think about this...")] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + // Should have: ReasoningAdded, SummaryPartAdded, TextDelta, TextDone, SummaryDone, ReasoningDone, Completed + Assert.True(events.Count >= 5, $"Expected at least 5 events for reasoning, got {events.Count}"); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_CancellationRequested_Throws() + { + var (stream, _) = CreateTestStream(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var updates = ToAsync(new[] { new AgentResponseUpdate { Contents = [new MeaiTextContent("test")] } }); + + await Assert.ThrowsAnyAsync(async () => + { + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream, cts.Token)) + { + // Should throw before yielding + } + }); + } + + // F-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_EmptyTextContent_NoTextDeltaEmitted() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.DoesNotContain(events, e => e is ResponseTextDeltaEvent); + Assert.Contains(events, e => e is ResponseCompletedEvent); + } + + // F-04 + [Fact] + public async Task ConvertUpdatesToEventsAsync_NullTextContent_NoTextDeltaEmitted() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent((string)null!)] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.DoesNotContain(events, e => e is ResponseTextDeltaEvent); + Assert.Contains(events, e => e is ResponseCompletedEvent); + } + + // F-07 + [Fact] + public async Task ConvertUpdatesToEventsAsync_DifferentMessageIds_CreatesMultipleMessages() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("First")] }, + new AgentResponseUpdate { MessageId = "msg_2", Contents = [new MeaiTextContent("Second")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // F-08 + [Fact] + public async Task ConvertUpdatesToEventsAsync_NullMessageIds_TreatedAsSameMessage() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = null, Contents = [new MeaiTextContent("First")] }, + new AgentResponseUpdate { MessageId = null, Contents = [new MeaiTextContent("Second")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Single(events.OfType()); + } + + // G-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallClosesOpenMessage() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("thinking...")] }, + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "search", new Dictionary { ["q"] = "test" })] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // G-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallWithNullArguments_EmitsEmptyJson() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_1", "do_something", (IDictionary?)null)] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.IsType(events[^1]); + } + + // G-04 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallWithEmptyCallId_GeneratesCallId() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new FunctionCallContent("", "do_something", new Dictionary { ["x"] = 1 })] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseOutputItemAddedEvent); + } + + // G-05 + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultipleFunctionCalls_EmitsSeparateBuilders() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "func_a", new Dictionary { ["a"] = 1 })] }, + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_2", "func_b", new Dictionary { ["b"] = 2 })] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // H-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReasoningWithNullText_EmitsEmptyString() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new TextReasoningContent(null)] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.True(events.Count >= 5, $"Expected at least 5 events, got {events.Count}"); + Assert.IsType(events[^1]); + } + + // H-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReasoningClosesOpenMessage() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial")] }, + new AgentResponseUpdate { Contents = [new TextReasoningContent("thinking")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // I-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorContentWithNullMessage_UsesDefaultMessage() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new ErrorContent(null!)] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseFailedEvent); + } + + // I-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorContentClosesOpenMessage() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial text")] }, + new AgentResponseUpdate { Contents = [new ErrorContent("Something broke")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.True(events.OfType().Any()); + Assert.IsType(events[^1]); + } + + // I-06 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorAfterPartialText_ClosesMessageThenFails() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial text")] }, + new AgentResponseUpdate { Contents = [new ErrorContent("Unexpected error")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.True(events.OfType().Any()); + Assert.IsType(events[^1]); + Assert.DoesNotContain(events, e => e is ResponseCompletedEvent); + } + + // J-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultipleUsageUpdates_AccumulatesTokens() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Hi")] }, + new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 5, TotalTokenCount = 15 })] }, + new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 20, OutputTokenCount = 10, TotalTokenCount = 30 })] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseCompletedEvent); + } + + // J-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_UsageWithZeroTokens_StillCompletes() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new UsageContent(new UsageDetails { InputTokenCount = 0, OutputTokenCount = 0, TotalTokenCount = 0 })] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseCompletedEvent); + } + + // K-01 + [Fact] + public async Task ConvertUpdatesToEventsAsync_DataContent_IsSkippedWithNoEvents() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new DataContent("data:image/png;base64,aWNv", "image/png")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Single(events); + Assert.IsType(events[0]); + } + + // K-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_UriContent_IsSkippedWithNoEvents() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new UriContent("https://example.com/file.txt", "text/plain")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Single(events); + Assert.IsType(events[0]); + } + + // K-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionResultContent_IsSkippedWithNoEvents() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "result data")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Single(events); + Assert.IsType(events[0]); + } + + // L-01 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ExecutorInvokedEvent_EmitsWorkflowActionItem() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("executor_1", "invoked") }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseOutputItemAddedEvent); + Assert.Contains(events, e => e is ResponseOutputItemDoneEvent); + Assert.IsType(events[^1]); + } + + // L-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ExecutorCompletedEvent_EmitsCompletedWorkflowAction() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("executor_1", null) }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseOutputItemAddedEvent); + Assert.Contains(events, e => e is ResponseOutputItemDoneEvent); + Assert.IsType(events[^1]); + } + + // L-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ExecutorFailedEvent_EmitsFailedWorkflowAction() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { RawRepresentation = new ExecutorFailedEvent("executor_1", new InvalidOperationException("test error")) }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseOutputItemAddedEvent); + Assert.Contains(events, e => e is ResponseOutputItemDoneEvent); + Assert.IsType(events[^1]); + } + + // L-04 + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowEventClosesOpenMessage() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("exec_1", "invoked") }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // L-06 + [Fact] + public async Task ConvertUpdatesToEventsAsync_InterleavedWorkflowAndTextEvents() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("exec_1", "invoked") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Agent says hello")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("exec_1", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(3, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // M-01 + [Fact] + public async Task ConvertUpdatesToEventsAsync_TextThenFunctionCallThenText_ProducesCorrectSequence() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Let me check...")] }, + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "search", new Dictionary { ["q"] = "weather" })] }, + new AgentResponseUpdate { MessageId = "msg_2", Contents = [new MeaiTextContent("Here are the results")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(3, events.OfType().Count()); + } + + // M-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReasoningThenText_ProducesCorrectSequence() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { Contents = [new TextReasoningContent("Thinking about the answer...")] }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("The answer is 42")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // M-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_TextThenError_EmitsMessageThenFailed() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Starting...")] }, + new AgentResponseUpdate { Contents = [new ErrorContent("Unexpected error")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.IsType(events[^1]); + Assert.DoesNotContain(events, e => e is ResponseCompletedEvent); + Assert.Single(events.OfType()); + } + + // M-04 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallThenTextThenFunctionCall_ProducesThreeItems() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "func_a", new Dictionary { ["a"] = 1 })] }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Processing...")] }, + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_2", "func_b", new Dictionary { ["b"] = 2 })] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(3, events.OfType().Count()); + } + + // ===== Workflow content flow tests (W series) ===== + // These simulate the exact update patterns that WorkflowSession.InvokeStageAsync() produces + // when wrapping a Workflow as an AIAgent via AsAIAgent(). + + // W-01: Multi-executor text output — different MessageIds cause separate messages + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultiExecutorTextOutput_CreatesSeparateMessages() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + // Executor 1 invoked (RawRepresentation) + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + // Executor 1 produces text (unwrapped AgentResponseUpdateEvent) + new AgentResponseUpdate { MessageId = "msg_agent1", Contents = [new MeaiTextContent("Hello from agent 1")] }, + // Executor 1 completed + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + // Executor 2 invoked + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") }, + // Executor 2 produces text (different MessageId) + new AgentResponseUpdate { MessageId = "msg_agent2", Contents = [new MeaiTextContent("Hello from agent 2")] }, + // Executor 2 completed + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 2 workflow action items (invoked) + 1 text message + 2 workflow action items (completed) + 1 text message = 6 output items + Assert.Equal(6, events.OfType().Count()); + // 2 text deltas (one per agent) + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // W-02: Workflow error via ErrorContent (as produced by WorkflowSession for WorkflowErrorEvent) + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowErrorAsContent_EmitsFailed() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Starting work...")] }, + // WorkflowErrorEvent is converted to ErrorContent by WorkflowSession + new AgentResponseUpdate { Contents = [new ErrorContent("Workflow execution failed")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Should close the open message, then emit failed + Assert.True(events.OfType().Any()); + Assert.IsType(events[^1]); + Assert.DoesNotContain(events, e => e is ResponseCompletedEvent); + } + + // W-03: Function call from workflow executor (e.g. handoff agent calling transfer_to_agent) + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowFunctionCall_EmitsFunctionCallEvents() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("triage_agent", "start") }, + // Agent produces function call (handoff) + new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_handoff", "transfer_to_code_expert", + new Dictionary { ["reason"] = "User asked about code" })] + }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("triage_agent", null) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("code_expert", "start") }, + new AgentResponseUpdate { MessageId = "msg_expert", Contents = [new MeaiTextContent("Here's how async/await works...")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("code_expert", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Should have: 4 workflow actions + 1 function call + 1 text message = 6 output items + Assert.Equal(6, events.OfType().Count()); + Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent); + Assert.Contains(events, e => e is ResponseTextDeltaEvent); + Assert.IsType(events[^1]); + } + + // W-04: Informational events (superstep, workflow started) are silently skipped + [Fact] + public async Task ConvertUpdatesToEventsAsync_InformationalWorkflowEvents_AreSkipped() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("start") }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Result")] }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Only one output item (the text message), no workflow action items for informational events + Assert.Single(events.OfType()); + Assert.Contains(events, e => e is ResponseTextDeltaEvent); + Assert.IsType(events[^1]); + } + + // W-05: Warning events are silently skipped + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowWarningEvent_IsSkipped() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new WorkflowWarningEvent("Agent took too long") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Done")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Single(events.OfType()); + Assert.IsType(events[^1]); + } + + // W-06: Streaming text from multiple workflow turns (same executor, different message IDs) + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultiTurnSameExecutor_CreatesSeparateMessages() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_turn1", Contents = [new MeaiTextContent("First response")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + // Same executor invoked again (second superstep) + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_turn2", Contents = [new MeaiTextContent("Second response")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 4 workflow action items + 2 text messages = 6 output items + Assert.Equal(6, events.OfType().Count()); + Assert.Equal(2, events.OfType().Count()); + } + + // W-07: Executor failure mid-stream with partial text + [Fact] + public async Task ConvertUpdatesToEventsAsync_ExecutorFailureAfterPartialText_ClosesMessageAndEmitsFailure() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Starting to process...")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorFailedEvent("agent_1", new InvalidOperationException("Agent crashed")) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Text message should be closed before the failed workflow action item + Assert.True(events.OfType().Any()); + // Workflow action items: invoked + failed = 2, plus text message = 3 + Assert.Equal(3, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // W-08: Full handoff pattern — triage → function call → target agent text + [Fact] + public async Task ConvertUpdatesToEventsAsync_FullHandoffPattern_ProducesCorrectEventSequence() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + // Workflow lifecycle + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("triage", "start") }, + // Triage agent decides to hand off + new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_1", "transfer_to_expert", + new Dictionary { ["reason"] = "technical question" })] + }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("triage", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + // Next superstep + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("expert", "start") }, + // Expert agent responds with text + new AgentResponseUpdate { MessageId = "msg_expert_1", Contents = [new MeaiTextContent("Let me explain...")] }, + new AgentResponseUpdate { MessageId = "msg_expert_1", Contents = [new MeaiTextContent(" Here's how it works.")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("expert", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Workflow actions: invoked triage, completed triage, invoked expert, completed expert = 4 + // Content items: 1 function call, 1 text message = 2 + // Total output items: 6 + Assert.Equal(6, events.OfType().Count()); + Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent); + // Two text deltas for the two streaming chunks + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // W-09: SubworkflowErrorEvent treated as informational (error content comes separately) + [Fact] + public async Task ConvertUpdatesToEventsAsync_SubworkflowErrorEvent_IsSkipped() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new SubworkflowErrorEvent("sub_1", new InvalidOperationException("sub failed")) }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Recovered")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // SubworkflowErrorEvent extends WorkflowErrorEvent which falls through to default skip + Assert.Single(events.OfType()); + Assert.IsType(events[^1]); + } + + // W-10: Mixed content types from workflow — reasoning + text + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowReasoningThenText_ProducesCorrectSequence() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("thinking_agent", "start") }, + // Agent produces reasoning content + new AgentResponseUpdate { Contents = [new TextReasoningContent("Analyzing the problem...")] }, + // Then text response + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("The answer is 42")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("thinking_agent", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Workflow actions: 2 (invoked + completed), reasoning: 1, text message: 1 = 4 output items + Assert.Equal(4, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // W-11: Usage content accumulated across workflow executors + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowUsageAcrossExecutors_AccumulatesCorrectly() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Response 1")] }, + new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 100, OutputTokenCount = 50, TotalTokenCount = 150 })] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") }, + new AgentResponseUpdate { MessageId = "msg_2", Contents = [new MeaiTextContent("Response 2")] }, + new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 200, OutputTokenCount = 100, TotalTokenCount = 300 })] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Usage should be accumulated in the completed event + Assert.IsType(events[^1]); + } + + // W-12: Empty workflow — only lifecycle events, no content + [Fact] + public async Task ConvertUpdatesToEventsAsync_EmptyWorkflowOnlyLifecycle_EmitsOnlyCompleted() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("start") }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Only the terminal completed event + Assert.Single(events); + Assert.IsType(events[0]); + } + + private static async IAsyncEnumerable ToAsync(IEnumerable source) + { + foreach (var item in source) + { + yield return item; + } + + await Task.CompletedTask; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..9cf45d70af --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Azure.AI.AgentServer.Responses; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddAgentFrameworkHandler_RegistersResponseHandler() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddAgentFrameworkHandler(); + + var descriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(ResponseHandler)); + Assert.NotNull(descriptor); + Assert.Equal(typeof(AgentFrameworkResponseHandler), descriptor.ImplementationType); + } + + [Fact] + public void AddAgentFrameworkHandler_CalledTwice_RegistersOnce() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddAgentFrameworkHandler(); + services.AddAgentFrameworkHandler(); + + var count = services.Count(d => d.ServiceType == typeof(ResponseHandler)); + Assert.Equal(1, count); + } + + [Fact] + public void AddAgentFrameworkHandler_NullServices_ThrowsArgumentNullException() + { + Assert.Throws( + () => AgentFrameworkResponsesServiceCollectionExtensions.AddAgentFrameworkHandler(null!)); + } + + [Fact] + public void AddAgentFrameworkHandler_WithAgent_RegistersAgentAndHandler() + { + var services = new ServiceCollection(); + services.AddLogging(); + var mockAgent = new Mock(); + + services.AddAgentFrameworkHandler(mockAgent.Object); + + var handlerDescriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(ResponseHandler)); + Assert.NotNull(handlerDescriptor); + + var agentDescriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(AIAgent)); + Assert.NotNull(agentDescriptor); + } + + [Fact] + public void AddAgentFrameworkHandler_WithNullAgent_ThrowsArgumentNullException() + { + var services = new ServiceCollection(); + Assert.Throws( + () => services.AddAgentFrameworkHandler((AIAgent)null!)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs new file mode 100644 index 0000000000..e5f9bb0405 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs @@ -0,0 +1,509 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; + +/// +/// Integration tests that verify workflow execution through the +/// pipeline. +/// These use real workflow builders and the InProcessExecution environment +/// to produce authentic streaming event patterns. +/// +public class WorkflowIntegrationTests +{ + private static string ValidResponseId => "resp_" + new string('0', 46); + + // ===== Sequential Workflow Tests ===== + + [Fact] + public async Task SequentialWorkflow_SingleAgent_ProducesTextOutput() + { + // Arrange: single-agent sequential workflow + var echoAgent = new StreamingTextAgent("echo", "Hello from the workflow!"); + var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential", echoAgent); + var workflowAgent = workflow.AsAIAgent( + id: "workflow-agent", + name: "Test Workflow", + executionEnvironment: InProcessExecution.OffThread, + includeExceptionDetails: true); + + var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello"); + + // Act + var events = await CollectEventsAsync(handler, request, context); + + // Assert: should have lifecycle events + at least one text output + terminal + Assert.IsType(events[0]); + Assert.IsType(events[1]); + Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}"); + + var lastEvent = events[^1]; + Assert.True( + lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent, + $"Expected terminal event, got {lastEvent.GetType().Name}"); + } + + [Fact] + public async Task SequentialWorkflow_TwoAgents_ProducesOutputFromBoth() + { + // Arrange: two agents in sequence + var agent1 = new StreamingTextAgent("agent1", "First agent says hello"); + var agent2 = new StreamingTextAgent("agent2", "Second agent says goodbye"); + var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential-2", agent1, agent2); + var workflowAgent = workflow.AsAIAgent( + id: "seq-workflow", + name: "Sequential Workflow", + executionEnvironment: InProcessExecution.OffThread, + includeExceptionDetails: true); + + var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Process this"); + + // Act + var events = await CollectEventsAsync(handler, request, context); + + // Assert: should have workflow action events for executor lifecycle + var lastEvent = events[^1]; + Assert.True( + lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent, + $"Expected terminal event, got {lastEvent.GetType().Name}"); + + // Should have output item events (either text messages or workflow actions) + Assert.True(events.OfType().Any(), + "Expected at least one output item from the workflow"); + } + + // ===== Workflow Error Propagation ===== + + [Fact] + public async Task Workflow_AgentThrowsException_ProducesErrorOutput() + { + // Arrange: workflow with an agent that throws + var throwingAgent = new ThrowingStreamingAgent("thrower", new InvalidOperationException("Agent crashed")); + var workflow = AgentWorkflowBuilder.BuildSequential("test-error", throwingAgent); + var workflowAgent = workflow.AsAIAgent( + id: "error-workflow", + name: "Error Workflow", + executionEnvironment: InProcessExecution.OffThread, + includeExceptionDetails: true); + + var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Trigger error"); + + // Act + var events = await CollectEventsAsync(handler, request, context); + + // Assert: should have lifecycle events + error/failure indicator + Assert.IsType(events[0]); + Assert.IsType(events[1]); + + var lastEvent = events[^1]; + // Workflow errors surface as either Failed or Completed (depending on error handling) + Assert.True( + lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent, + $"Expected terminal event, got {lastEvent.GetType().Name}"); + } + + // ===== Workflow Action Lifecycle Events ===== + + [Fact] + public async Task Workflow_ExecutorEvents_ProduceWorkflowActionItems() + { + // Arrange + var agent = new StreamingTextAgent("test-agent", "Result"); + var workflow = AgentWorkflowBuilder.BuildSequential("test-actions", agent); + var workflowAgent = workflow.AsAIAgent( + id: "actions-workflow", + name: "Actions Workflow", + executionEnvironment: InProcessExecution.OffThread); + + var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello"); + + // Act + var events = await CollectEventsAsync(handler, request, context); + + // Assert: workflow should produce OutputItemAdded events for executor lifecycle + var addedEvents = events.OfType().ToList(); + Assert.True(addedEvents.Count >= 1, + $"Expected at least 1 output item added event, got {addedEvents.Count}"); + } + + // ===== Keyed Workflow Registration ===== + + [Fact] + public async Task WorkflowAgent_RegisteredWithKey_ResolvesCorrectly() + { + // Arrange: workflow agent registered with a keyed service name + var agent = new StreamingTextAgent("inner", "Keyed workflow response"); + var workflow = AgentWorkflowBuilder.BuildSequential("keyed-wf", agent); + var workflowAgent = workflow.AsAIAgent( + id: "keyed-workflow", + name: "Keyed Workflow", + executionEnvironment: InProcessExecution.OffThread); + + var services = new ServiceCollection(); + services.AddKeyedSingleton("my-workflow", workflowAgent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("my-workflow")); + request.Input = CreateUserInput("Test keyed workflow"); + var mockContext = CreateMockContext(); + + // Act + var events = await CollectEventsAsync(handler, request, mockContext.Object); + + // Assert + Assert.IsType(events[0]); + Assert.True(events.Count >= 3, $"Expected at least 3 events, got {events.Count}"); + } + + // ===== OutputConverter Direct Workflow Pattern Tests ===== + // These test the OutputConverter directly with update patterns that mirror real workflows. + + [Fact] + public async Task OutputConverter_SequentialWorkflowPattern_ProducesCorrectEvents() + { + // Simulate what WorkflowSession produces for a 2-agent sequential workflow + var (stream, _) = CreateTestStream(); + var updates = new[] + { + // Superstep 1: Agent 1 + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_a1", Contents = [new MeaiTextContent("Agent 1 output")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + // Superstep 2: Agent 2 + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") }, + new AgentResponseUpdate { MessageId = "msg_a2", Contents = [new MeaiTextContent("Agent 2 output")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 4 workflow action items + 2 text messages = 6 output items + Assert.Equal(6, events.OfType().Count()); + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task OutputConverter_GroupChatPattern_ProducesCorrectEvents() + { + // Simulate round-robin group chat: agent1 → agent2 → agent1 → terminate + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") }, + new AgentResponseUpdate { MessageId = "msg_gc_1", Contents = [new MeaiTextContent("Agent 1 turn 1")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_2", "turn") }, + new AgentResponseUpdate { MessageId = "msg_gc_2", Contents = [new MeaiTextContent("Agent 2 turn 1")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_2", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(3) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") }, + new AgentResponseUpdate { MessageId = "msg_gc_3", Contents = [new MeaiTextContent("Agent 1 turn 2")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(3) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 6 workflow actions + 3 text messages = 9 output items + Assert.Equal(9, events.OfType().Count()); + Assert.Equal(3, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task OutputConverter_CodeExecutorPattern_ProducesCorrectEvents() + { + // Simulate a code-based FunctionExecutor: invoked → completed, no text content + // (code executors don't produce AgentResponseUpdateEvent, just executor lifecycle) + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("uppercase_fn", "hello") }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("uppercase_fn", "HELLO") }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + // Second executor uses the output + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("format_agent", "start") }, + new AgentResponseUpdate { MessageId = "msg_fmt", Contents = [new MeaiTextContent("Formatted: HELLO")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("format_agent", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 4 workflow actions + 1 text message = 5 output items + Assert.Equal(5, events.OfType().Count()); + Assert.Single(events.OfType()); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task OutputConverter_SubworkflowPattern_ProducesCorrectEvents() + { + // Simulate a parent workflow that invokes a sub-workflow executor + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("parent") }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + // Sub-workflow executor invoked + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("sub_workflow_host", "start") }, + // Inner agent within sub-workflow produces text (unwrapped by WorkflowSession) + new AgentResponseUpdate { MessageId = "msg_sub_1", Contents = [new MeaiTextContent("Sub-workflow agent output")] }, + // Sub-workflow executor completed + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("sub_workflow_host", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 2 workflow actions + 1 text message = 3 output items + Assert.Equal(3, events.OfType().Count()); + Assert.Single(events.OfType()); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task OutputConverter_WorkflowWithMultipleContentTypes_HandlesAllCorrectly() + { + // Simulate a workflow producing reasoning, text, function calls, and usage + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("planner", "start") }, + // Reasoning + new AgentResponseUpdate { Contents = [new TextReasoningContent("Let me think about this...")] }, + // Function call (tool use) + new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_search", "web_search", + new Dictionary { ["query"] = "latest news" })] + }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("planner", null) }, + // Next executor uses tool result + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("writer", "start") }, + new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("Based on my research, ")] }, + new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("here are the findings.")] }, + new AgentResponseUpdate + { + Contents = [new UsageContent(new UsageDetails { InputTokenCount = 500, OutputTokenCount = 200, TotalTokenCount = 700 })] + }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("writer", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Workflow actions: 4 (2 invoked + 2 completed) + // Content: 1 reasoning + 1 function call + 1 text message = 3 + // Total: 7 output items + Assert.Equal(7, events.OfType().Count()); + Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent); + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // ===== Helpers ===== + + private static (AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context) + CreateHandlerWithAgent(AIAgent agent, string userMessage) + { + var services = new ServiceCollection(); + services.AddSingleton(agent); + services.AddSingleton>(NullLogger.Instance); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = CreateUserInput(userMessage); + var mockContext = CreateMockContext(); + + return (handler, request, mockContext.Object); + } + + private static BinaryData CreateUserInput(string text) + { + return BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_in_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text } } + } + }); + } + + private static Mock CreateMockContext() + { + var mock = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mock.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mock.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + return mock; + } + + private static (ResponseEventStream stream, Mock mockContext) CreateTestStream() + { + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model"); + var stream = new ResponseEventStream(mockContext.Object, request); + return (stream, mockContext); + } + + private static async Task> CollectEventsAsync( + AgentFrameworkResponseHandler handler, + CreateResponse request, + ResponseContext context) + { + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, context, CancellationToken.None)) + { + events.Add(evt); + } + + return events; + } + + private static async IAsyncEnumerable ToAsync(IEnumerable source) + { + foreach (var item in source) + { + yield return item; + } + + await Task.CompletedTask; + } + + // ===== Test Agent Types ===== + + /// + /// A test agent that streams a single text update. + /// + private sealed class StreamingTextAgent(string id, string responseText) : AIAgent + { + public new string Id => id; + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return new AgentResponseUpdate + { + MessageId = $"msg_{id}", + Contents = [new MeaiTextContent(responseText)] + }; + + await Task.CompletedTask; + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + /// + /// A test agent that always throws an exception during streaming. + /// + private sealed class ThrowingStreamingAgent(string id, Exception exception) : AIAgent + { + public new string Id => id; + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw exception; + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } +}