+
+
+
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)
+
+ 🕐 Time in Tokyo
+ 🌤️ Weather in Seattle
+ 📚 Azure Functions docs
+ 📚 Agent Framework
+
+
+
+
+
+
+
+
+
+""";
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // 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
+
+
+ 💻 Reverse linked list
+ ✍️ Cloud haiku
+ 💻 Async vs threads
+ ✍️ AI painter story
+
+
+
+
+
+
+
+
+
+""";
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // 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