.NET [WIP] Foundry Hosted Agents Support (#5312)

* Add Azure AI Foundry Responses hosting adapter

Implement Microsoft.Agents.AI.Hosting.AzureAIResponses to host agent-framework
AIAgents and workflows within Azure Foundry as hosted agents via the
Azure.AI.AgentServer.Responses SDK.

- AgentFrameworkResponseHandler: bridges ResponseHandler to AIAgent execution
- InputConverter: converts Responses API inputs/history to MEAI ChatMessage
- OutputConverter: converts agent response updates to SSE event stream
- ServiceCollectionExtensions: DI registration helpers
- 336 unit tests across net8.0/net9.0/net10.0 (112 per TFM)
- ResponseStreamValidator: SSE protocol validation tool for samples
- FoundryResponsesHosting sample app

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump System.ClientModel to 1.10.0 for Azure.Core 1.52.0 compat

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Clean up tests and sample formatting

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update Azure.AI.AgentServer packages to 1.0.0-alpha.20260401.5

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add hosted package version suffix (0.9.0-hosted) to distinguish from mainline

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Move Foundry Responses hosting into Microsoft.Agents.AI.Foundry package

Move source and test files from the standalone Hosting.AzureAIResponses project
into the Foundry package under a Hosting/ subfolder. This consolidates the
Foundry-specific hosting adapter into the main Foundry package.

- Source: Microsoft.Agents.AI.Foundry.Hosting namespace
- Tests: merged into Foundry.UnitTests/Hosting/
- Conditionally compiled for .NETCoreApp TFMs only (net8.0+)
- Deleted standalone Hosting.AzureAIResponses project and test project
- Updated sample and solution references

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump package version to 0.9.0-hosted.260402.2

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump OpenTelemetry packages to fix NU1109 downgrade errors

- OpenTelemetry/Api/Exporter.Console/Exporter.InMemory: 1.13.1 -> 1.15.0
- OpenTelemetry.Exporter.OpenTelemetryProtocol: already 1.15.0
- OpenTelemetry.Extensions.Hosting: already 1.14.0
- OpenTelemetry.Instrumentation.AspNetCore/Http: already 1.14.0
- OpenTelemetry.Instrumentation.Runtime: 1.13.0 -> 1.14.0
- Azure.Monitor.OpenTelemetry.Exporter: 1.4.0 -> 1.5.0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CA1873: guard LogWarning with IsEnabled check

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix model override bug and add client REPL sample

- InputConverter: stop propagating request.Model to ChatOptions.ModelId
  Hosted agents use their own model; client-provided model values like
  'hosted-agent' were being passed through and causing server errors.
- Add FoundryResponsesRepl sample: interactive CLI client that connects
  to a Foundry Responses endpoint using ResponsesClient.AsAIAgent()
- Bump package version to 0.9.0-hosted.260403.1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Catch agent errors and emit response.failed with real error message

Previously, unhandled exceptions from agent execution would bubble up
to the SDK orchestrator, which emits a generic 'An internal server
error occurred.' message — hiding the actual cause (e.g., 401 auth
failures, model not found, etc.).

Now AgentFrameworkResponseHandler catches non-cancellation exceptions
and emits a proper response.failed event containing the real error
message, making it visible to clients and in logs.

OperationCanceledException still propagates for proper cancellation
handling by the SDK.

Also bumps package version to 0.9.0-hosted.260403.2.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Renaming and merging hosting extensions. (#5091)

* Rename AddAgentFrameworkHandler to AddFoundryResponses and add MapFoundryResponses

- Rename extension methods: AddAgentFrameworkHandler -> AddFoundryResponses, MapAgentFrameworkHandler -> MapFoundryResponses
- AddFoundryResponses now calls AddResponsesServer() internally
- Add MapFoundryResponses() extension on IEndpointRouteBuilder
- Update sample and tests to use new API names
- Remove redundant AddResponsesServer() and /ready endpoint from sample

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fixing numbering in sample.

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address breaking changes in 260408

* Bump hosted internal package version

* Add UserAgent middleware tests for Foundry hosting

* Hosting Samples update

* Hosting Samples update

* Hosting Samples update

* Hosting Samples update

* ChatClientAgent working

* Adding SessionStorage and SessionManagement, improving samples to align Consumption vs Hosting

* Using updates

* Update chat client agent for contributor and devs

* Foundry Agent Hosting

* Address text rag sample working

* Version bump

* Adding LocalTools + Workflow samples

* Removing extra using samples

* Add Hosted-McpTools sample with dual MCP pattern

Demonstrates two MCP integration layers in a single hosted agent:
- Client-side MCP: McpClient connects to Microsoft Learn, agent handles
  tool invocations locally (docs_search, code_sample_search, docs_fetch)
- Server-side MCP: HostedMcpServerTool delegates tool discovery and
  invocation to the LLM provider (Responses API), no local connection

Includes DevTemporaryTokenCredential for Docker local debugging,
Dockerfile.contributor for ProjectReference builds, and the openai/v1
route mapping for AIProjectClient compatibility in Development mode.

* .NET: Bump Azure.AI.AgentServer packages to 1.0.0-beta.1/beta.21 and fix br… (#5287)

* Bump Azure.AI.AgentServer packages to 1.0.0-beta.1/beta.21 and fix breaking API changes

- Azure.AI.AgentServer.Core: 1.0.0-beta.11 -> 1.0.0-beta.21
- Azure.AI.AgentServer.Invocations: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1
- Azure.AI.AgentServer.Responses: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1
- Azure.Identity: 1.20.0 -> 1.21.0 (transitive requirement)
- Azure.Core: 1.52.0 -> 1.53.0 (transitive requirement)
- Remove azure-sdk-for-net dev feed (packages now on nuget.org)
- Fix OutputConverter for new builder API (auto-tracked children, split EmitTextDone/EmitDone)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fixing small issues.

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Azure AI Foundry Responses hosting adapter

Implement Microsoft.Agents.AI.Hosting.AzureAIResponses to host agent-framework
AIAgents and workflows within Azure Foundry as hosted agents via the
Azure.AI.AgentServer.Responses SDK.

- AgentFrameworkResponseHandler: bridges ResponseHandler to AIAgent execution
- InputConverter: converts Responses API inputs/history to MEAI ChatMessage
- OutputConverter: converts agent response updates to SSE event stream
- ServiceCollectionExtensions: DI registration helpers
- 336 unit tests across net8.0/net9.0/net10.0 (112 per TFM)
- ResponseStreamValidator: SSE protocol validation tool for samples
- FoundryResponsesHosting sample app

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump System.ClientModel to 1.10.0 for Azure.Core 1.52.0 compat

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Clean up tests and sample formatting

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update Azure.AI.AgentServer packages to 1.0.0-alpha.20260401.5

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add hosted package version suffix (0.9.0-hosted) to distinguish from mainline

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Move Foundry Responses hosting into Microsoft.Agents.AI.Foundry package

Move source and test files from the standalone Hosting.AzureAIResponses project
into the Foundry package under a Hosting/ subfolder. This consolidates the
Foundry-specific hosting adapter into the main Foundry package.

- Source: Microsoft.Agents.AI.Foundry.Hosting namespace
- Tests: merged into Foundry.UnitTests/Hosting/
- Conditionally compiled for .NETCoreApp TFMs only (net8.0+)
- Deleted standalone Hosting.AzureAIResponses project and test project
- Updated sample and solution references

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump package version to 0.9.0-hosted.260402.2

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump OpenTelemetry packages to fix NU1109 downgrade errors

- OpenTelemetry/Api/Exporter.Console/Exporter.InMemory: 1.13.1 -> 1.15.0
- OpenTelemetry.Exporter.OpenTelemetryProtocol: already 1.15.0
- OpenTelemetry.Extensions.Hosting: already 1.14.0
- OpenTelemetry.Instrumentation.AspNetCore/Http: already 1.14.0
- OpenTelemetry.Instrumentation.Runtime: 1.13.0 -> 1.14.0
- Azure.Monitor.OpenTelemetry.Exporter: 1.4.0 -> 1.5.0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CA1873: guard LogWarning with IsEnabled check

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix model override bug and add client REPL sample

- InputConverter: stop propagating request.Model to ChatOptions.ModelId
  Hosted agents use their own model; client-provided model values like
  'hosted-agent' were being passed through and causing server errors.
- Add FoundryResponsesRepl sample: interactive CLI client that connects
  to a Foundry Responses endpoint using ResponsesClient.AsAIAgent()
- Bump package version to 0.9.0-hosted.260403.1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Catch agent errors and emit response.failed with real error message

Previously, unhandled exceptions from agent execution would bubble up
to the SDK orchestrator, which emits a generic 'An internal server
error occurred.' message — hiding the actual cause (e.g., 401 auth
failures, model not found, etc.).

Now AgentFrameworkResponseHandler catches non-cancellation exceptions
and emits a proper response.failed event containing the real error
message, making it visible to clients and in logs.

OperationCanceledException still propagates for proper cancellation
handling by the SDK.

Also bumps package version to 0.9.0-hosted.260403.2.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Renaming and merging hosting extensions. (#5091)

* Rename AddAgentFrameworkHandler to AddFoundryResponses and add MapFoundryResponses

- Rename extension methods: AddAgentFrameworkHandler -> AddFoundryResponses, MapAgentFrameworkHandler -> MapFoundryResponses
- AddFoundryResponses now calls AddResponsesServer() internally
- Add MapFoundryResponses() extension on IEndpointRouteBuilder
- Update sample and tests to use new API names
- Remove redundant AddResponsesServer() and /ready endpoint from sample

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fixing numbering in sample.

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address breaking changes in 260408

* Bump hosted internal package version

* Add UserAgent middleware tests for Foundry hosting

* Hosting Samples update

* Hosting Samples update

* Hosting Samples update

* Hosting Samples update

* ChatClientAgent working

* Adding SessionStorage and SessionManagement, improving samples to align Consumption vs Hosting

* Using updates

* Update chat client agent for contributor and devs

* Foundry Agent Hosting

* Address text rag sample working

* Version bump

* Adding LocalTools + Workflow samples

* Removing extra using samples

* Add Hosted-McpTools sample with dual MCP pattern

Demonstrates two MCP integration layers in a single hosted agent:
- Client-side MCP: McpClient connects to Microsoft Learn, agent handles
  tool invocations locally (docs_search, code_sample_search, docs_fetch)
- Server-side MCP: HostedMcpServerTool delegates tool discovery and
  invocation to the LLM provider (Responses API), no local connection

Includes DevTemporaryTokenCredential for Docker local debugging,
Dockerfile.contributor for ProjectReference builds, and the openai/v1
route mapping for AIProjectClient compatibility in Development mode.

* Bump Azure.AI.AgentServer packages to 1.0.0-beta.1/beta.21 and fix breaking API changes

- Azure.AI.AgentServer.Core: 1.0.0-beta.11 -> 1.0.0-beta.21
- Azure.AI.AgentServer.Invocations: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1
- Azure.AI.AgentServer.Responses: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1
- Azure.Identity: 1.20.0 -> 1.21.0 (transitive requirement)
- Azure.Core: 1.52.0 -> 1.53.0 (transitive requirement)
- Remove azure-sdk-for-net dev feed (packages now on nuget.org)
- Fix OutputConverter for new builder API (auto-tracked children, split EmitTextDone/EmitDone)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fixing small issues.

* Fix IDE0009: add 'this' qualification in DevTemporaryTokenCredential

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix IDE0009: add 'this' qualification in all HostedAgentsV2 samples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CHARSET: add UTF-8 BOM to Hosted-LocalTools and Hosted-Workflows

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix dotnet format: add Async suffix to test methods (IDE1006), fix encoding and style

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Register AgentSessionStore in test DI setups

Add InMemoryAgentSessionStore registration to all ServiceCollection
setups in AgentFrameworkResponseHandlerTests and WorkflowIntegrationTests.
This is needed after the AgentSessionStore infrastructure was introduced
in the responses-hosting feature. Tests still have NotImplementedException
stubs for CreateSessionCoreAsync which will be fixed when the session
infrastructure is fully available.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Invocations protocol samples (hosted echo agent + client) (#5278)

Add Hosted-Invocations-EchoAgent: a minimal echo agent hosted via the
Invocations protocol (POST /invocations) using AddInvocationsServer and
MapInvocationsServer, bridged to an Agent Framework AIAgent through a
custom InvocationHandler.

Add SimpleInvocationsAgent: a console REPL client that wraps HttpClient
calls to the /invocations endpoint in a custom InvocationsAIAgent,
demonstrating programmatic consumption of the Invocations protocol.

Both samples default to port 8088 for consistency with other hosted
agent samples.

* Restructure FoundryHostedAgents samples into invocations/ and responses/

Align dotnet hosted agent samples with the Python side (PR #5281) by
reorganizing the directory structure:

- Remove HostedAgentsV1 entirely (old API pattern)
- Split HostedAgentsV2 into invocations/ and responses/ based on protocol
- Move Using-Samples accordingly (SimpleAgent to responses, SimpleInvocationsAgent to invocations)
- Update slnx with new project paths and add previously missing invocations projects
- Update README cd paths from HostedAgentsV2 to invocations or responses
- Rename .env.local to .env.example to match Python naming convention
- Fix format violations in newly included invocations projects

* Remove launchSettings, use .env for port configuration

- Delete all launchSettings.json files (port 8088 now comes from ASPNETCORE_URLS in .env)
- Add DotNetEnv to Hosted-Invocations-EchoAgent so it loads .env like the responses samples
- Create .env.example for EchoAgent with ASPNETCORE_URLS and ASPNETCORE_ENVIRONMENT
- Add AGENT_NAME to ChatClientAgent and FoundryAgent .env.example (required by those samples)
- Add AZURE_BEARER_TOKEN=DefaultAzureCredential to all .env.example files
- Update DevTemporaryTokenCredential in all 6 samples to treat the sentinel value
  as unavailable, allowing ChainedTokenCredential to fall through to DefaultAzureCredential
- Update EchoAgent README with Configuration section

* Use placeholder for AGENT_NAME in Hosted-FoundryAgent .env.example

* Move FoundryResponsesHosting to responses/Hosted-WorkflowHandoff, use GetResponsesClient

* Rename Hosted-Workflows to Hosted-Workflow-Simple, Hosted-WorkflowHandoff to Hosted-Workflow-Handoff

* Remove FoundryResponsesRepl and empty FoundryResponsesHosting directory

* Add Dockerfiles, README, agent yamls and bearer token support to Hosted-Workflow-Handoff

- Add Dockerfile and Dockerfile.contributor for Docker-based testing
- Add agent.yaml and agent.manifest.yaml with triage-workflow as primary agent
- Add README.md following sibling pattern, noting Azure OpenAI vs Foundry endpoint
- Add DevTemporaryTokenCredential and ChainedTokenCredential for Docker auth
- Register triage-workflow as non-keyed default so azd invoke works without model
- Update .env.example with AZURE_BEARER_TOKEN sentinel
- Add .gitignore to 04-hosting to suppress VS-generated launchSettings.json
- Fix docker run image name in Hosted-Workflow-Simple README

* Fix AgentFrameworkResponseHandlerTests: implement session methods in test mock agents

* .NET: Auto-instrument resolved AIAgents with OpenTelemetry for Foundry Hosted Agents (#5316)

* Auto-instrument resolved AIAgents with OpenTelemetry using Core ResponsesSourceName

* Add OTel telemetry capture tests for Foundry hosted agent handler

* Net: Prepare Foundry Preview Release (#5336)

* Prepare Foundry preview release 1.2.0-preview.*

Bump VersionPrefix to 1.2.0 and update the preview stamp date. Invert packaging opt-in so only the Foundry preview set produces NuGet packages:

- Microsoft.Agents.AI.Abstractions

- Microsoft.Agents.AI

- Microsoft.Agents.AI.Workflows

- Microsoft.Agents.AI.Workflows.Generators

- Microsoft.Agents.AI.Foundry

Flip IsReleased=false on the preview set so they pick up the -preview.YYMMDD.N suffix. Gate GeneratePackageOnBuild on IsPackable=true. Remove the global IsPackable=true from nuget-package.props so the repo-level default (false) applies to everything else.

* Lower preview VersionPrefix to 0.0.1

Retroactive preview publish: bump VersionPrefix and GitTag from 1.2.0 to 0.0.1 so the 5 Foundry preview packages emit as 0.0.1-preview.260417.1.

* Net: Publish all packages as 0.0.1-preview.260417.2 (#5341)

Revises the Foundry pre-release approach to publish ALL normally packable src projects as preview packages stamped 0.0.1-preview.260417.2, including projects previously flagged IsReleased=true or with a non-default VersionSuffix (rc/alpha).

nuget-package.props:

- Collapse the four conditional PackageVersion expressions (IsReleaseCandidate, VersionSuffix, default preview, IsReleased stable) into a single unconditional 0.0.1-preview.260417.2. On this preview-only branch every package ships with the same pre-release stamp regardless of per-project flags.

- Restore the global IsPackable=true default (offsetting the repo-wide IsPackable=false in Directory.Build.props). Projects that opt out (Mem0, Declarative) already set IsPackable=false AFTER importing this file so they remain non-packable.

- Remove the IsReleased-gated EnablePackageValidation line. Package validation does not apply to a 0.0.1 preview.

csproj reverts (Abstractions, Agents.AI, Workflows, Workflows.Generators, Foundry):

- Revert the IsPackable=true opt-in block introduced in #5336 (now redundant since the props default is true again).

- Restore IsReleased=true to its pre-PR value. The setting is now a no-op because the props no longer branches on it.

* Bump preview version to 260420.1 and fix AgentServer package deps (#5367)

- Bump PackageVersion to 0.0.1-preview.260420.1
- Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by
  Azure.AI.AgentServer.Responses beta.3)
- Replace AgentHostTelemetry.ResponsesSourceName with local constant
  (type made internal in AgentServer.Core beta.22)

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Hosted agents toolbox support (#5368)

* feat: Add Foundry Toolbox (MCP) support to AgentFrameworkResponseHandler

Adds support for Foundry Toolsets MCP proxy integration in the hosted agent
response handler. Toolsets connect at startup via IHostedService, gating the
readiness probe per spec §3.1. MCP tools are injected into every request's
ChatOptions and OAuth consent errors (-32006) are intercepted and surfaced as
mcp_approval_request + incomplete SSE events.

New files:
- FoundryToolboxOptions.cs: configuration POCO for toolset names and API version
- FoundryToolboxBearerTokenHandler.cs: DelegatingHandler with Azure Bearer token
  auth, Foundry-Features header injection, and 3x exponential backoff on 429/5xx
- McpConsentContext.cs: AsyncLocal-based per-request consent state shared between
  the tool wrapper and the response handler
- ConsentAwareMcpClientTool.cs: AIFunction wrapper that catches -32006 errors and
  signals consent via shared state and linked CancellationTokenSource
- FoundryToolboxService.cs: IHostedService that creates McpClient per toolset at
  startup and exposes cached tools

Modified files:
- AgentFrameworkResponseHandler.cs: injects toolbox tools into ChatOptions, sets
  up linked CTS consent interception, emits mcp_approval_request on -32006
- ServiceCollectionExtensions.cs: adds AddFoundryToolboxes(params string[]) extension
- Microsoft.Agents.AI.Foundry.csproj: adds ModelContextProtocol and Azure.Identity
  dependencies under NETCoreApp condition

Sample:
- Hosted-Toolbox: minimal hosted agent sample using AddFoundryToolboxes

* Rename toolset to toolbox in user-facing API; rename ConsentAwareMcpClientTool to ConsentAwareMcpClientAIFunction

* Add HostedMcpToolboxAITool for client-selectable Foundry toolboxes

Introduces HostedMcpToolboxAITool, a marker tool subclassing HostedMcpServerTool that rides the OpenAI Responses 'mcp' wire format to let clients request a specific Foundry toolbox per request.

- New FoundryAITool.CreateHostedMcpToolbox(name, version?) factory.

- FoundryToolboxOptions.StrictMode (default true) rejects unregistered toolboxes; set to false to allow lazy-open on first use.

- FoundryToolboxService.GetToolboxToolsAsync(name, version?) resolves cached or lazy-opened MCP tools.

- AgentFrameworkResponseHandler parses request.Tools for foundry-toolbox://name[?version=v] markers and injects resolved tools per request, merging with pre-registered ones.

- Unit tests for marker parsing and strict-mode resolution.

* Bump Azure.AI.Projects to 2.1.0-alpha; add ToolboxRecord/ToolboxVersion factory overloads + tests

* Fix PR review issues: retry off-by-one, URI encoding, docs, tests, build

- Fix off-by-one in FoundryToolboxBearerTokenHandler retry loop (4 attempts → 3)
- URI-encode version parameter in HostedMcpToolboxAITool.BuildAddress
- Add XML doc clarifying version pinning is reserved for future use
- Add comment clarifying AddHostedService deduplication safety
- Fix DevTemporaryTokenCredential expiry to use DateTimeOffset.MaxValue
- Fix AgentCard ambiguity in A2AServer sample with using alias
- Add 18 new unit tests for retry handler and ReadMcpToolboxMarkers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Hosted agent adapter (#5371)

* Bump preview version to 260420.1 and fix AgentServer package deps

- Bump PackageVersion to 0.0.1-preview.260420.1
- Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by
  Azure.AI.AgentServer.Responses beta.3)
- Replace AgentHostTelemetry.ResponsesSourceName with local constant
  (type made internal in AgentServer.Core beta.22)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CA1873: guard LogError with IsEnabled check in FoundryToolboxService

Wrap the LogError call with an IsEnabled(LogLevel.Error) guard to satisfy
the CA1873 analyzer rule which flags potentially expensive argument
evaluation when logging is disabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Hosted agent adapter (#5374)

* Bump preview version to 260420.1 and fix AgentServer package deps

- Bump PackageVersion to 0.0.1-preview.260420.1
- Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by
  Azure.AI.AgentServer.Responses beta.3)
- Replace AgentHostTelemetry.ResponsesSourceName with local constant
  (type made internal in AgentServer.Core beta.22)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CA1873: guard LogError with IsEnabled check in FoundryToolboxService

Wrap the LogError call with an IsEnabled(LogLevel.Error) guard to satisfy
the CA1873 analyzer rule which flags potentially expensive argument
evaluation when logging is disabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bumping NuGet version

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Hosted agent adapter (#5406)

* Bump preview version to 260420.1 and fix AgentServer package deps

- Bump PackageVersion to 0.0.1-preview.260420.1
- Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by
  Azure.AI.AgentServer.Responses beta.3)
- Replace AgentHostTelemetry.ResponsesSourceName with local constant
  (type made internal in AgentServer.Core beta.22)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CA1873: guard LogError with IsEnabled check in FoundryToolboxService

Wrap the LogError call with an IsEnabled(LogLevel.Error) guard to satisfy
the CA1873 analyzer rule which flags potentially expensive argument
evaluation when logging is disabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bumping NuGet version

* Restore conditional versioning, remove dev feed, bump Azure.AI.Projects to beta.1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Hosted agent adapter (#5408)

* Bump preview version to 260420.1 and fix AgentServer package deps

- Bump PackageVersion to 0.0.1-preview.260420.1
- Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by
  Azure.AI.AgentServer.Responses beta.3)
- Replace AgentHostTelemetry.ResponsesSourceName with local constant
  (type made internal in AgentServer.Core beta.22)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CA1873: guard LogError with IsEnabled check in FoundryToolboxService

Wrap the LogError call with an IsEnabled(LogLevel.Error) guard to satisfy
the CA1873 analyzer rule which flags potentially expensive argument
evaluation when logging is disabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bumping NuGet version

* Restore conditional versioning, remove dev feed, bump Azure.AI.Projects to beta.1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR #5312 review comments

- Add comment explaining NU1903 suppression (Microsoft.Bcl.Memory transitive vuln)
- Remove NU1903 from sample/test projects where not needed
- Fix Dockerfile ENTRYPOINT mismatch in Hosted-Workflow-Simple
- Align agent name to 'hosted-workflow-simple' in agent.yaml and README
- Fix Hosted-McpTools README: replace GitHub PAT refs with Microsoft Learn
- Fix session persistence: only persist when client provides conversation ID
- Upgrade IsNullOrEmpty to IsNullOrWhiteSpace for session ID checks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Split Foundry into stable V1 and preview Hosting package

Extract hosted agent functionality from Microsoft.Agents.AI.Foundry into a
new Microsoft.Agents.AI.Foundry.Hosting preview package. This resolves NU5104
build errors caused by the stable Foundry package depending on prerelease
Azure SDK packages (Azure.AI.AgentServer.Responses, Azure.AI.Projects beta).

Changes:
- Create Microsoft.Agents.AI.Foundry.Hosting with VersionSuffix=preview,
  targeting .NET Core only (net8.0/9.0/10.0)
- Move all Hosting/ source files to the new project
- Move ToolboxRecord/ToolboxVersion overloads to FoundryAIToolExtensions
- Revert Azure.AI.Projects to 2.0.0 in Directory.Packages.props;
  Hosting uses VersionOverride for 2.1.0-beta.1
- Clean V1 Foundry csproj: remove beta deps, ASP.NET Core ref, hosting conditionals
- Update 8 hosted agent sample projects to reference Foundry.Hosting
- Split unit tests: ToolboxRecord/ToolboxVersion tests moved to Hosting/
- Add Foundry.Hosting to solution file

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review comments: experimental attrs, doc fixes, token propagation

- Add [Experimental(OPENAI001)] to all 7 public Hosting types per reviewer request
- Fix McpConsentContext XML doc: 'Thread-static' -> 'Async-local' (AsyncLocal
  flows with ExecutionContext, not thread-static)
- Expand UserAgentMiddleware test regex to match prerelease versions (e.g. 1.0.0-rc.4)
- Propagate CancellationToken in AgentFrameworkResponseHandler session save

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove unnecessary MEAI001 suppression from stable Foundry package

MEAI001 was a leftover from when Hosting code lived in the same project.
The stable V1 Foundry package builds clean without it, and suppressing
experimental diagnostics in a released package can hide unintentional
exposure of experimental APIs to consumers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Foundry.Hosting to release solution filter

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>
This commit is contained in:
Roger Barreto
2026-04-21 20:25:10 +00:00
committed by GitHub
co-authored by Copilot alliscode Ben Thomas
parent 57fa8ea902
commit f2b215a2f6
158 changed files with 10872 additions and 2351 deletions
@@ -0,0 +1,96 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
public class HostedMcpToolboxAIToolTests
{
[Fact]
public void Ctor_NameOnly_BuildsMarkerAddress()
{
var tool = new HostedMcpToolboxAITool("my-toolbox");
Assert.Equal("my-toolbox", tool.ToolboxName);
Assert.Null(tool.Version);
Assert.Equal("my-toolbox", tool.ServerName);
Assert.Equal("foundry-toolbox://my-toolbox", tool.ServerAddress);
Assert.Equal("mcp", tool.Name);
}
[Fact]
public void Ctor_WithVersion_IncludesVersionQuery()
{
var tool = new HostedMcpToolboxAITool("my-toolbox", "v3");
Assert.Equal("v3", tool.Version);
Assert.Equal("foundry-toolbox://my-toolbox?version=v3", tool.ServerAddress);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Ctor_InvalidName_Throws(string? name)
{
Assert.ThrowsAny<ArgumentException>(() => new HostedMcpToolboxAITool(name!));
}
[Fact]
public void TryParseToolboxAddress_NameOnly_ReturnsTrue()
{
var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(
"foundry-toolbox://my-toolbox", out var name, out var version);
Assert.True(ok);
Assert.Equal("my-toolbox", name);
Assert.Null(version);
}
[Fact]
public void TryParseToolboxAddress_WithVersion_ExtractsVersion()
{
var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(
"foundry-toolbox://my-toolbox?version=v3", out var name, out var version);
Assert.True(ok);
Assert.Equal("my-toolbox", name);
Assert.Equal("v3", version);
}
[Theory]
[InlineData("https://example.com/mcp")]
[InlineData("not-a-url")]
[InlineData("")]
[InlineData(null)]
public void TryParseToolboxAddress_NonMarker_ReturnsFalse(string? address)
{
var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(address, out var name, out var version);
Assert.False(ok);
Assert.Null(name);
Assert.Null(version);
}
[Fact]
public void TryParseToolboxAddress_RoundTripsFromBuild()
{
var address = HostedMcpToolboxAITool.BuildAddress("box", "2025-06-01");
var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(address, out var name, out var version);
Assert.True(ok);
Assert.Equal("box", name);
Assert.Equal("2025-06-01", version);
}
[Fact]
public void FoundryAITool_CreateHostedMcpToolbox_ReturnsMarker()
{
var tool = FoundryAITool.CreateHostedMcpToolbox("my-toolbox", "v1");
var marker = Assert.IsType<HostedMcpToolboxAITool>(tool);
Assert.Equal("my-toolbox", marker.ToolboxName);
Assert.Equal("v1", marker.Version);
}
}
@@ -0,0 +1,237 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using OpenTelemetry;
using OpenTelemetry.Trace;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
/// <summary>
/// Tests that verify OTel spans are actually emitted and captured through the
/// <see cref="AgentFrameworkResponseHandler"/> pipeline when
/// <see cref="FoundryHostingExtensions.ApplyOpenTelemetry"/> wraps the resolved agent.
/// </summary>
public class AgentFrameworkResponseHandlerTelemetryTests
{
/// <summary>
/// The ActivitySource name used by ApplyOpenTelemetry() — equals AgentHostTelemetry.ResponsesSourceName.
/// Declared as a constant so the TracerProvider and assertions reference the same literal.
/// </summary>
private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses";
[Fact]
public async Task CreateAsync_DefaultAgent_EmitsInvokeAgentSpanAsync()
{
// Arrange
var activities = new List<Activity>();
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(ResponsesSourceName)
.AddInMemoryExporter(activities)
.Build();
var agent = new TelemetryTestAgent();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var (request, context) = BuildRequest();
// Act — enumerate all events so the span completes before asserting
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
// Assert — filter by agent name to isolate this test's span from any parallel test spans
var mySpan = Assert.Single(activities.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
Assert.Equal("invoke_agent", mySpan.GetTagItem("gen_ai.operation.name"));
Assert.NotNull(mySpan.GetTagItem("gen_ai.agent.id"));
}
[Fact]
public async Task CreateAsync_KeyedAgent_EmitsInvokeAgentSpanAsync()
{
// Arrange
var activities = new List<Activity>();
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(ResponsesSourceName)
.AddInMemoryExporter(activities)
.Build();
var agent = new TelemetryTestAgent();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton<AIAgent>("keyed-agent", agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var (request, context) = BuildRequest(agentKey: "keyed-agent");
// Act
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
// Assert — filter by agent name to isolate this test's span
var mySpan = Assert.Single(activities.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
Assert.Equal("invoke_agent", mySpan.GetTagItem("gen_ai.operation.name"));
}
[Fact]
public async Task CreateAsync_AlreadyInstrumentedAgent_EmitsSingleSpanPerRunAsync()
{
// Arrange — use a unique source for the pre-wrapped agent distinct from ResponsesSourceName.
// If ApplyOpenTelemetry double-wraps, an extra span would appear on ResponsesSourceName.
// If it correctly skips wrapping, only the pre-wrap's unique source emits spans.
var preWrapSource = Guid.NewGuid().ToString();
var preWrapActivities = new List<Activity>();
var responsesActivities = new List<Activity>();
using var preWrapProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(preWrapSource)
.AddInMemoryExporter(preWrapActivities)
.Build();
using var responsesProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(ResponsesSourceName)
.AddInMemoryExporter(responsesActivities)
.Build();
var innerAgent = new TelemetryTestAgent();
var preWrapped = innerAgent.AsBuilder()
.UseOpenTelemetry(sourceName: preWrapSource)
.Build();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton(preWrapped);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
// Act
var (request, context) = BuildRequest();
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
// Assert — pre-wrap source emits exactly 1 span (agent ran)
Assert.Single(preWrapActivities);
Assert.Equal("invoke_agent", preWrapActivities[0].GetTagItem("gen_ai.operation.name"));
// ResponsesSourceName emits 0 spans — ApplyOpenTelemetry skipped wrapping the pre-instrumented agent
Assert.DoesNotContain(responsesActivities, a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name")));
}
[Fact]
public async Task CreateAsync_DefaultAgent_SpanDisplayNameContainsAgentNameAsync()
{
// Arrange
var activities = new List<Activity>();
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(ResponsesSourceName)
.AddInMemoryExporter(activities)
.Build();
var agent = new TelemetryTestAgent();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var (request, context) = BuildRequest();
// Act
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
// Assert — display name follows "invoke_agent {Name}({Id})" convention; filter by agent name to isolate
var mySpan = Assert.Single(activities.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
Assert.Contains("invoke_agent", mySpan.DisplayName, StringComparison.Ordinal);
Assert.Contains(TelemetryTestAgent.AgentName, mySpan.DisplayName, StringComparison.Ordinal);
}
private static (CreateResponse request, ResponseContext context) BuildRequest(string? agentKey = null)
{
var request = agentKey is null
? AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test")
: AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference(agentKey));
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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
return (request, mockContext.Object);
}
private sealed class TelemetryTestAgent : AIAgent
{
public const string AgentName = "TelemetryTestAgent";
public override string? Name => AgentName;
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
SingleUpdateAsync(new AgentResponseUpdate
{
MessageId = "resp_msg_1",
Contents = [new MeaiTextContent("telemetry test response")]
}, cancellationToken);
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
new(new TelemetryAgentSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(new TelemetryAgentSession());
private static async IAsyncEnumerable<AgentResponseUpdate> SingleUpdateAsync(
AgentResponseUpdate update,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield return update;
}
}
private sealed class TelemetryAgentSession : AgentSession;
}
@@ -0,0 +1,870 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
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.Foundry.UnitTests.Hosting;
public class AgentFrameworkResponseHandlerTests
{
[Fact]
public async Task CreateAsync_WithDefaultAgent_ProducesStreamEventsAsync()
{
// Arrange
var agent = CreateTestAgent("Hello from the agent!");
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act
var events = new List<ResponseStreamEvent>();
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<ResponseCreatedEvent>(events[0]);
Assert.IsType<ResponseInProgressEvent>(events[1]);
}
[Fact]
public async Task CreateAsync_WithKeyedAgent_ResolvesCorrectAgentAsync()
{
// Arrange
var agent = CreateTestAgent("Keyed agent response");
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton<AIAgent>("my-agent", agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act
var events = new List<ResponseStreamEvent>();
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<ResponseCreatedEvent>(events[0]);
}
[Fact]
public async Task CreateAsync_NoAgentRegistered_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
{
}
});
}
[Fact]
public void Constructor_NullServiceProvider_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(
() => new AgentFrameworkResponseHandler(null!, NullLogger<AgentFrameworkResponseHandler>.Instance));
}
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNullException()
{
var sp = new ServiceCollection().BuildServiceProvider();
Assert.Throws<ArgumentNullException>(
() => new AgentFrameworkResponseHandler(sp, null!));
}
[Fact]
public async Task CreateAsync_ResolvesAgentByModelFieldAsync()
{
// Arrange
var agent = CreateTestAgent("model agent");
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton<AIAgent>("my-agent", agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act
var events = new List<ResponseStreamEvent>();
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
{
events.Add(evt);
}
// Assert
Assert.True(events.Count >= 4);
Assert.IsType<ResponseCreatedEvent>(events[0]);
}
[Fact]
public async Task CreateAsync_ResolvesAgentByEntityIdMetadataAsync()
{
// Arrange
var agent = CreateTestAgent("entity agent");
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton<AIAgent>("entity-agent", agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act
var events = new List<ResponseStreamEvent>();
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
{
events.Add(evt);
}
// Assert
Assert.True(events.Count >= 4);
Assert.IsType<ResponseCreatedEvent>(events[0]);
}
[Fact]
public async Task CreateAsync_NamedAgentNotFound_FallsBackToDefaultAsync()
{
// Arrange
var agent = CreateTestAgent("default agent");
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act
var events = new List<ResponseStreamEvent>();
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
{
events.Add(evt);
}
// Assert
Assert.True(events.Count >= 4);
Assert.IsType<ResponseCreatedEvent>(events[0]);
}
[Fact]
public async Task CreateAsync_NoAgentFound_ErrorMessageIncludesAgentNameAsync()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
{
}
});
Assert.Contains("missing-agent", ex.Message);
}
[Fact]
public async Task CreateAsync_NoAgentNoName_ErrorMessageIsGenericAsync()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(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_ExceptionHasNoEventsAsync()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act
var events = new List<ResponseStreamEvent>();
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_PrependsHistoryToMessagesAsync()
{
// Arrange
var agent = new CapturingAgent();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<Annotation>(),
Array.Empty<LogProb>())],
status: MessageStatus.Completed);
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new OutputItem[] { historyItem });
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act
var events = new List<ResponseStreamEvent>();
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_UsesResolvedInputItemsAsync()
{
// Arrange
var agent = new CapturingAgent();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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 ItemMessage(
MessageRole.Assistant,
[new MessageContentInputTextContent("Resolved input")]);
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Item[] { inputItem });
// Act
var events = new List<ResponseStreamEvent>();
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_FallsBackToRawRequestInputAsync()
{
// Arrange
var agent = new CapturingAgent();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act
var events = new List<ResponseStreamEvent>();
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_PassesInstructionsToAgentAsync()
{
// Arrange
var agent = new CapturingAgent();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act
var events = new List<ResponseStreamEvent>();
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
{
events.Add(evt);
}
// Assert
Assert.NotNull(agent.CapturedOptions);
var chatClientOptions = Assert.IsType<ChatClientAgentRunOptions>(agent.CapturedOptions);
Assert.Equal("You are a helpful assistant.", chatClientOptions.ChatOptions?.Instructions);
}
[Fact]
public async Task CreateAsync_AgentThrows_EmitsFailedEventWithErrorMessageAsync()
{
// Arrange
var agent = new ThrowingAgent(new InvalidOperationException("Agent crashed"));
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act — collect all events
var events = new List<ResponseStreamEvent>();
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
{
events.Add(evt);
}
// Assert — should contain created, in_progress, and failed (with real error message)
Assert.Contains(events, e => e is ResponseCreatedEvent);
Assert.Contains(events, e => e is ResponseInProgressEvent);
var failedEvent = Assert.Single(events.OfType<ResponseFailedEvent>());
Assert.Contains("Agent crashed", failedEvent.Response.Error.Message);
}
[Fact]
public async Task CreateAsync_MultipleKeyedAgents_ResolvesCorrectOneAsync()
{
// Arrange
var agent1 = CreateTestAgent("Agent 1 response");
var agent2 = CreateTestAgent("Agent 2 response");
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton<AIAgent>("agent-1", agent1);
services.AddKeyedSingleton<AIAgent>("agent-2", agent2);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act
var events = new List<ResponseStreamEvent>();
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
{
events.Add(evt);
}
// Assert
Assert.True(events.Count >= 4);
Assert.IsType<ResponseCreatedEvent>(events[0]);
}
[Fact]
public async Task CreateAsync_CancellationDuringExecution_PropagatesOperationCanceledExceptionAsync()
{
// Arrange
var agent = new CancellationCheckingAgent();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
using var cts = new CancellationTokenSource();
cts.Cancel();
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await foreach (var _ in handler.CreateAsync(request, mockContext.Object, cts.Token))
{
}
});
}
[Fact]
public async Task CreateAsync_DefaultAgent_IsAutoWrappedWithOpenTelemetryAsync()
{
// Arrange — register a plain (non-instrumented) agent
var agent = CreateTestAgent("otel test response");
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.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<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act — OTel wrapping must not break the stream
var events = new List<ResponseStreamEvent>();
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
{
events.Add(evt);
}
// Assert — stream events are still produced correctly through the wrapper
Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}");
Assert.IsType<ResponseCreatedEvent>(events[0]);
Assert.IsType<ResponseInProgressEvent>(events[1]);
}
private static TestAgent CreateTestAgent(string responseText)
{
return new TestAgent(responseText);
}
private static async IAsyncEnumerable<AgentResponseUpdate> 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<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
ToAsyncEnumerableAsync(new AgentResponseUpdate
{
MessageId = "resp_msg_1",
Contents = [new MeaiTextContent(responseText)]
});
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
new(new SimpleAgentSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(new SimpleAgentSession());
}
private sealed class ThrowingAgent(Exception exception) : AIAgent
{
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw exception;
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
new(new SimpleAgentSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(new SimpleAgentSession());
}
private sealed class CapturingAgent : AIAgent
{
public IEnumerable<ChatMessage>? CapturedMessages { get; private set; }
public AgentRunOptions? CapturedOptions { get; private set; }
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default)
{
this.CapturedMessages = messages.ToList();
this.CapturedOptions = options;
return ToAsyncEnumerableAsync(new AgentResponseUpdate
{
MessageId = "resp_msg_1",
Contents = [new MeaiTextContent("captured")]
});
}
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
new(new SimpleAgentSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(new SimpleAgentSession());
}
private sealed class CancellationCheckingAgent : AIAgent
{
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> 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<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
new(new SimpleAgentSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(new SimpleAgentSession());
}
private sealed class SimpleAgentSession : AgentSession { }
}
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Foundry.Hosting;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class FoundryAIToolExtensionsTests
{
[Fact]
public void CreateHostedMcpToolbox_FromToolboxRecord_UsesNameAndDefaultVersion()
{
var record = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxRecord(
id: "tbx-123",
name: "calendar-tools",
defaultVersion: "v2");
var tool = FoundryAIToolExtensions.CreateHostedMcpToolbox(record);
var marker = Assert.IsType<HostedMcpToolboxAITool>(tool);
Assert.Equal("calendar-tools", marker.ToolboxName);
Assert.Equal("v2", marker.Version);
Assert.Equal("foundry-toolbox://calendar-tools?version=v2", marker.ServerAddress);
}
[Fact]
public void CreateHostedMcpToolbox_FromToolboxRecord_NullDefaultVersionOmitsQuery()
{
var record = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxRecord(
id: "tbx-abc",
name: "finance-tools",
defaultVersion: null);
var tool = FoundryAIToolExtensions.CreateHostedMcpToolbox(record);
var marker = Assert.IsType<HostedMcpToolboxAITool>(tool);
Assert.Equal("finance-tools", marker.ToolboxName);
Assert.Null(marker.Version);
Assert.Equal("foundry-toolbox://finance-tools", marker.ServerAddress);
}
[Fact]
public void CreateHostedMcpToolbox_FromToolboxRecord_Null_Throws()
{
Assert.Throws<ArgumentNullException>(
() => FoundryAIToolExtensions.CreateHostedMcpToolbox((Azure.AI.Projects.Agents.ToolboxRecord)null!));
}
[Fact]
public void CreateHostedMcpToolbox_FromToolboxVersion_UsesNameAndVersion()
{
var version = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxVersion(
metadata: null,
id: "ver-1",
name: "hr-tools",
version: "2025-09-01",
description: "HR toolbox",
createdAt: DateTimeOffset.UtcNow,
tools: null,
policies: null);
var tool = FoundryAIToolExtensions.CreateHostedMcpToolbox(version);
var marker = Assert.IsType<HostedMcpToolboxAITool>(tool);
Assert.Equal("hr-tools", marker.ToolboxName);
Assert.Equal("2025-09-01", marker.Version);
Assert.Equal("foundry-toolbox://hr-tools?version=2025-09-01", marker.ServerAddress);
}
[Fact]
public void CreateHostedMcpToolbox_FromToolboxVersion_Null_Throws()
{
Assert.Throws<ArgumentNullException>(
() => FoundryAIToolExtensions.CreateHostedMcpToolbox((Azure.AI.Projects.Agents.ToolboxVersion)null!));
}
}
@@ -0,0 +1,185 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Agents.AI.Foundry.Hosting;
using Moq;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class FoundryToolboxBearerTokenHandlerTests
{
private const string FakeToken = "test-bearer-token";
private static Mock<TokenCredential> CreateMockCredential()
{
var mock = new Mock<TokenCredential>();
mock.Setup(c => c.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.UtcNow.AddHours(1)));
return mock;
}
private static (FoundryToolboxBearerTokenHandler Handler, CountingHandler Inner) CreateHandlerPair(
Mock<TokenCredential>? credential = null,
string? featuresHeader = null,
HttpStatusCode statusCode = HttpStatusCode.OK)
{
credential ??= CreateMockCredential();
var inner = new CountingHandler(statusCode);
var handler = new FoundryToolboxBearerTokenHandler(credential.Object, featuresHeader)
{
InnerHandler = inner
};
return (handler, inner);
}
[Fact]
public async Task SendAsync_InjectsBearerTokenAsync()
{
var (handler, _) = CreateHandlerPair();
using var invoker = new HttpMessageInvoker(handler);
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
using var response = await invoker.SendAsync(request, CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Bearer", request.Headers.Authorization?.Scheme);
Assert.Equal(FakeToken, request.Headers.Authorization?.Parameter);
}
[Fact]
public async Task SendAsync_InjectsFoundryFeaturesHeaderAsync()
{
var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2");
using var invoker = new HttpMessageInvoker(handler);
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
using var response = await invoker.SendAsync(request, CancellationToken.None);
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
Assert.Contains("feature1,feature2", values);
}
[Fact]
public async Task SendAsync_OmitsFeaturesHeaderWhenNullAsync()
{
var (handler, _) = CreateHandlerPair(featuresHeader: null);
using var invoker = new HttpMessageInvoker(handler);
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
using var response = await invoker.SendAsync(request, CancellationToken.None);
Assert.False(request.Headers.Contains("Foundry-Features"));
}
[Theory]
[InlineData(HttpStatusCode.OK)]
[InlineData(HttpStatusCode.Created)]
[InlineData(HttpStatusCode.BadRequest)]
[InlineData(HttpStatusCode.NotFound)]
public async Task SendAsync_NonRetryableStatusCode_ReturnsImmediatelyAsync(HttpStatusCode statusCode)
{
var (handler, inner) = CreateHandlerPair(statusCode: statusCode);
using var invoker = new HttpMessageInvoker(handler);
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
using var response = await invoker.SendAsync(request, CancellationToken.None);
Assert.Equal(statusCode, response.StatusCode);
Assert.Equal(1, inner.CallCount);
}
[Theory]
[InlineData(HttpStatusCode.TooManyRequests)]
[InlineData(HttpStatusCode.InternalServerError)]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
public async Task SendAsync_RetryableStatusCode_RetriesMaxTimesAsync(HttpStatusCode statusCode)
{
var (handler, inner) = CreateHandlerPair(statusCode: statusCode);
using var invoker = new HttpMessageInvoker(handler);
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
using var response = await invoker.SendAsync(request, CancellationToken.None);
// MaxRetries is 3, so exactly 3 total attempts (not 4).
Assert.Equal(3, inner.CallCount);
Assert.Equal(statusCode, response.StatusCode);
}
[Fact]
public async Task SendAsync_RetryableStatusCode_SucceedsOnSecondAttemptAsync()
{
// First call returns 503, second returns 200.
var inner = new SequenceHandler(
HttpStatusCode.ServiceUnavailable,
HttpStatusCode.OK);
var handler = new FoundryToolboxBearerTokenHandler(CreateMockCredential().Object, null)
{
InnerHandler = inner
};
using var invoker = new HttpMessageInvoker(handler);
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
using var response = await invoker.SendAsync(request, CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(2, inner.CallCount);
}
/// <summary>
/// A test handler that always returns the configured status code and counts how many times it was called.
/// </summary>
private sealed class CountingHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
private int _callCount;
public int CallCount => this._callCount;
public CountingHandler(HttpStatusCode statusCode)
{
this._statusCode = statusCode;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Interlocked.Increment(ref this._callCount);
return Task.FromResult(new HttpResponseMessage(this._statusCode));
}
}
/// <summary>
/// A test handler that returns status codes from a sequence, cycling through them.
/// </summary>
private sealed class SequenceHandler : HttpMessageHandler
{
private readonly HttpStatusCode[] _statusCodes;
private int _callCount;
public int CallCount => this._callCount;
public SequenceHandler(params HttpStatusCode[] statusCodes)
{
this._statusCodes = statusCodes;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var index = Interlocked.Increment(ref this._callCount) - 1;
var statusCode = index < this._statusCodes.Length
? this._statusCodes[index]
: this._statusCodes[^1];
return Task.FromResult(new HttpResponseMessage(statusCode));
}
}
}
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.Options;
using Moq;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class FoundryToolboxServiceTests
{
[Fact]
public async Task GetToolboxToolsAsync_StrictMode_ThrowsForUnknownToolboxAsync()
{
var options = new FoundryToolboxOptions { StrictMode = true };
var service = new FoundryToolboxService(
Options.Create(options),
Mock.Of<TokenCredential>());
// Act + Assert: no StartAsync so Tools is empty; unknown name in strict mode throws.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None));
Assert.Contains("missing", ex.Message, StringComparison.Ordinal);
Assert.Contains("StrictMode", ex.Message, StringComparison.Ordinal);
}
[Fact]
public async Task GetToolboxToolsAsync_NonStrictMode_RequiresEndpointAsync()
{
var options = new FoundryToolboxOptions { StrictMode = false };
var service = new FoundryToolboxService(
Options.Create(options),
Mock.Of<TokenCredential>());
// Without calling StartAsync, endpoint is not resolved so lazy-open fails clearly.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None));
Assert.Contains("FOUNDRY_AGENT_TOOLSET_ENDPOINT", ex.Message, StringComparison.Ordinal);
}
[Fact]
public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync()
{
// Ensure env var is not set (tests may run in any CI environment)
var saved = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", null);
try
{
var options = new FoundryToolboxOptions();
options.ToolboxNames.Add("any");
var service = new FoundryToolboxService(
Options.Create(options),
Mock.Of<TokenCredential>());
await service.StartAsync(CancellationToken.None);
Assert.Empty(service.Tools);
}
finally
{
Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", saved);
}
}
}
@@ -0,0 +1,767 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class InputConverterTests
{
[Fact]
public void ConvertInputToMessages_EmptyRequest_ReturnsEmptyList()
{
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(Array.Empty<object>());
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<FunctionResultContent>().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<FunctionCallContent>().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.Null(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<Annotation>(),
Array.Empty<LogProb>());
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<FunctionCallContent>().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<FunctionResultContent>().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<object>()
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
Assert.Single(messages);
var textContent = Assert.IsType<MeaiTextContent>(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<Annotation>(), Array.Empty<LogProb>());
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<MeaiTextContent>(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<FunctionCallContent>().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_NotSetFromRequest()
{
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model");
var options = InputConverter.ConvertToChatOptions(request);
// Model from the request is intentionally NOT propagated — the hosted agent uses its own model.
Assert.Null(options.ModelId);
}
// ── ReadMcpToolboxMarkers tests ──────────────────────────────────────────────
[Fact]
public void ReadMcpToolboxMarkers_NullTools_ReturnsEmpty()
{
var request = new CreateResponse();
// Tools defaults to null when not set via JSON deserialization.
var markers = InputConverter.ReadMcpToolboxMarkers(request);
Assert.Empty(markers);
}
[Fact]
public void ReadMcpToolboxMarkers_McpToolWithToolboxAddress_ReturnsMarker()
{
var request = new CreateResponse();
request.Tools.Add(new MCPTool("test-toolbox")
{
ServerUrl = new Uri("foundry-toolbox://my-toolbox")
});
var markers = InputConverter.ReadMcpToolboxMarkers(request);
Assert.Single(markers);
Assert.Equal("my-toolbox", markers[0].Name);
Assert.Null(markers[0].Version);
}
[Fact]
public void ReadMcpToolboxMarkers_McpToolWithVersionedAddress_ReturnsNameAndVersion()
{
var request = new CreateResponse();
request.Tools.Add(new MCPTool("test-toolbox")
{
ServerUrl = new Uri("foundry-toolbox://my-toolbox?version=v3")
});
var markers = InputConverter.ReadMcpToolboxMarkers(request);
Assert.Single(markers);
Assert.Equal("my-toolbox", markers[0].Name);
Assert.Equal("v3", markers[0].Version);
}
[Fact]
public void ReadMcpToolboxMarkers_McpToolWithNonToolboxUrl_SkipsIt()
{
var request = new CreateResponse();
request.Tools.Add(new MCPTool("external-mcp")
{
ServerUrl = new Uri("https://example.com/mcp")
});
var markers = InputConverter.ReadMcpToolboxMarkers(request);
Assert.Empty(markers);
}
[Fact]
public void ReadMcpToolboxMarkers_McpToolWithNullServerUrl_SkipsIt()
{
var request = new CreateResponse();
request.Tools.Add(new MCPTool("test") { ServerUrl = null });
var markers = InputConverter.ReadMcpToolboxMarkers(request);
Assert.Empty(markers);
}
[Fact]
public void ReadMcpToolboxMarkers_MixedTools_ReturnsOnlyToolboxMarkers()
{
var request = new CreateResponse();
request.Tools.Add(new MCPTool("external")
{
ServerUrl = new Uri("https://example.com/mcp")
});
request.Tools.Add(new MCPTool("toolbox-1")
{
ServerUrl = new Uri("foundry-toolbox://box-a")
});
request.Tools.Add(new MCPTool("toolbox-2")
{
ServerUrl = new Uri("foundry-toolbox://box-b?version=2025-01")
});
var markers = InputConverter.ReadMcpToolboxMarkers(request);
Assert.Equal(2, markers.Count);
Assert.Equal("box-a", markers[0].Name);
Assert.Null(markers[0].Version);
Assert.Equal("box-b", markers[1].Name);
Assert.Equal("2025-01", markers[1].Version);
}
}
@@ -0,0 +1,96 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using Azure.AI.AgentServer.Responses;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class ServiceCollectionExtensionsTests
{
[Fact]
public void AddFoundryResponses_RegistersResponseHandler()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddFoundryResponses();
var descriptor = services.FirstOrDefault(
d => d.ServiceType == typeof(ResponseHandler));
Assert.NotNull(descriptor);
Assert.Equal(typeof(AgentFrameworkResponseHandler), descriptor.ImplementationType);
}
[Fact]
public void AddFoundryResponses_CalledTwice_RegistersOnce()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddFoundryResponses();
services.AddFoundryResponses();
var count = services.Count(d => d.ServiceType == typeof(ResponseHandler));
Assert.Equal(1, count);
}
[Fact]
public void AddFoundryResponses_NullServices_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(
() => FoundryHostingExtensions.AddFoundryResponses(null!));
}
[Fact]
public void AddFoundryResponses_WithAgent_RegistersAgentAndHandler()
{
var services = new ServiceCollection();
services.AddLogging();
var mockAgent = new Mock<AIAgent>();
services.AddFoundryResponses(mockAgent.Object);
var handlerDescriptor = services.FirstOrDefault(
d => d.ServiceType == typeof(ResponseHandler));
Assert.NotNull(handlerDescriptor);
var agentDescriptor = services.FirstOrDefault(
d => d.ServiceType == typeof(AIAgent));
Assert.NotNull(agentDescriptor);
}
[Fact]
public void AddFoundryResponses_WithNullAgent_ThrowsArgumentNullException()
{
var services = new ServiceCollection();
Assert.Throws<ArgumentNullException>(
() => services.AddFoundryResponses(null!));
}
[Fact]
public void ApplyOpenTelemetry_NonInstrumentedAgent_WrapsWithOpenTelemetryAgent()
{
var mockAgent = new Mock<AIAgent>();
var result = FoundryHostingExtensions.ApplyOpenTelemetry(mockAgent.Object);
Assert.NotNull(result.GetService<OpenTelemetryAgent>());
}
[Fact]
public void ApplyOpenTelemetry_AlreadyInstrumentedAgent_ReturnsSameReference()
{
var mockAgent = new Mock<AIAgent>();
var instrumented = mockAgent.Object.AsBuilder()
.UseOpenTelemetry()
.Build();
var result = FoundryHostingExtensions.ApplyOpenTelemetry(instrumented);
Assert.Same(instrumented, result);
}
}
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
/// <summary>
/// Tests for the <c>AgentFrameworkUserAgentMiddleware</c> registered by
/// <see cref="FoundryHostingExtensions.MapFoundryResponses"/>.
/// </summary>
public sealed partial class UserAgentMiddlewareTests : IAsyncDisposable
{
private const string VersionedUserAgentPattern = @"agent-framework-dotnet/\d+\.\d+\.\d+(-[\w.]+)?";
private WebApplication? _app;
private HttpClient? _httpClient;
public async ValueTask DisposeAsync()
{
this._httpClient?.Dispose();
if (this._app != null)
{
await this._app.DisposeAsync();
}
}
[Fact]
public async Task MapFoundryResponses_NoUserAgentHeader_SetsAgentFrameworkUserAgentAsync()
{
// Arrange
await this.CreateTestServerAsync();
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
// Act
var response = await this._httpClient!.SendAsync(request);
var userAgent = await response.Content.ReadAsStringAsync();
// Assert
Assert.Matches(VersionedUserAgentPattern, userAgent);
}
[Fact]
public async Task MapFoundryResponses_WithExistingUserAgent_AppendsAgentFrameworkUserAgentAsync()
{
// Arrange
await this.CreateTestServerAsync();
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
request.Headers.TryAddWithoutValidation("User-Agent", "MyApp/1.0");
// Act
var response = await this._httpClient!.SendAsync(request);
var userAgent = await response.Content.ReadAsStringAsync();
// Assert
Assert.StartsWith("MyApp/1.0", userAgent);
Assert.Matches(VersionedUserAgentPattern, userAgent);
}
[Fact]
public async Task MapFoundryResponses_AlreadyContainsUserAgent_DoesNotDuplicateAsync()
{
// Arrange
await this.CreateTestServerAsync();
// First request to capture the actual middleware-generated value
using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
var firstResponse = await this._httpClient!.SendAsync(firstRequest);
var middlewareValue = await firstResponse.Content.ReadAsStringAsync();
// Act: send a second request that already contains the middleware value
using var secondRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
secondRequest.Headers.TryAddWithoutValidation("User-Agent", $"MyApp/2.0 {middlewareValue}");
var secondResponse = await this._httpClient!.SendAsync(secondRequest);
var userAgent = await secondResponse.Content.ReadAsStringAsync();
// Assert: should remain unchanged (no duplication)
Assert.Equal($"MyApp/2.0 {middlewareValue}", userAgent);
Assert.Single(VersionedUserAgentRegex().Matches(userAgent));
}
[Fact]
public async Task MapFoundryResponses_UserAgentValue_ContainsVersionAsync()
{
// Arrange
await this.CreateTestServerAsync();
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
// Act
var response = await this._httpClient!.SendAsync(request);
var userAgent = await response.Content.ReadAsStringAsync();
// Assert: should match "agent-framework-dotnet/x.y.z" pattern
Assert.Matches(VersionedUserAgentPattern, userAgent);
}
private async Task CreateTestServerAsync()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockAgent = new Mock<AIAgent>();
builder.Services.AddFoundryResponses(mockAgent.Object);
this._app = builder.Build();
this._app.MapFoundryResponses();
// Test endpoint that echoes the User-Agent header after middleware processing
this._app.MapGet("/test-ua", (HttpContext ctx) =>
Results.Text(ctx.Request.Headers.UserAgent.ToString()));
await this._app.StartAsync();
var testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
this._httpClient = testServer.CreateClient();
}
[GeneratedRegex(VersionedUserAgentPattern)]
private static partial Regex VersionedUserAgentRegex();
}
@@ -0,0 +1,510 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
/// <summary>
/// Integration tests that verify workflow execution through the
/// <see cref="AgentFrameworkResponseHandler"/> → <see cref="OutputConverter"/> pipeline.
/// These use real workflow builders and the InProcessExecution environment
/// to produce authentic streaming event patterns.
/// </summary>
public class WorkflowIntegrationTests
{
// ===== Sequential Workflow Tests =====
[Fact]
public async Task SequentialWorkflow_SingleAgent_ProducesTextOutputAsync()
{
// Arrange: single-agent sequential workflow
var echoAgent = new StreamingTextAgent("echo", "Hello from the workflow!");
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential", echoAgent);
var workflowAgent = workflow.AsAIAgent(
id: "workflow-agent",
name: "Test Workflow",
executionEnvironment: InProcessExecution.OffThread,
includeExceptionDetails: true);
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
// Act
var events = await CollectEventsAsync(handler, request, context);
// Assert: should have lifecycle events + at least one text output + terminal
Assert.IsType<ResponseCreatedEvent>(events[0]);
Assert.IsType<ResponseInProgressEvent>(events[1]);
Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}");
var lastEvent = events[^1];
Assert.True(
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
$"Expected terminal event, got {lastEvent.GetType().Name}");
}
[Fact]
public async Task SequentialWorkflow_TwoAgents_ProducesOutputFromBothAsync()
{
// Arrange: two agents in sequence
var agent1 = new StreamingTextAgent("agent1", "First agent says hello");
var agent2 = new StreamingTextAgent("agent2", "Second agent says goodbye");
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential-2", agent1, agent2);
var workflowAgent = workflow.AsAIAgent(
id: "seq-workflow",
name: "Sequential Workflow",
executionEnvironment: InProcessExecution.OffThread,
includeExceptionDetails: true);
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Process this");
// Act
var events = await CollectEventsAsync(handler, request, context);
// Assert: should have workflow action events for executor lifecycle
var lastEvent = events[^1];
Assert.True(
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
$"Expected terminal event, got {lastEvent.GetType().Name}");
// Should have output item events (either text messages or workflow actions)
Assert.True(events.OfType<ResponseOutputItemAddedEvent>().Any(),
"Expected at least one output item from the workflow");
}
// ===== Workflow Error Propagation =====
[Fact]
public async Task Workflow_AgentThrowsException_ProducesErrorOutputAsync()
{
// Arrange: workflow with an agent that throws
var throwingAgent = new ThrowingStreamingAgent("thrower", new InvalidOperationException("Agent crashed"));
var workflow = AgentWorkflowBuilder.BuildSequential("test-error", throwingAgent);
var workflowAgent = workflow.AsAIAgent(
id: "error-workflow",
name: "Error Workflow",
executionEnvironment: InProcessExecution.OffThread,
includeExceptionDetails: true);
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Trigger error");
// Act
var events = await CollectEventsAsync(handler, request, context);
// Assert: should have lifecycle events + error/failure indicator
Assert.IsType<ResponseCreatedEvent>(events[0]);
Assert.IsType<ResponseInProgressEvent>(events[1]);
var lastEvent = events[^1];
// Workflow errors surface as either Failed or Completed (depending on error handling)
Assert.True(
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
$"Expected terminal event, got {lastEvent.GetType().Name}");
}
// ===== Workflow Action Lifecycle Events =====
[Fact]
public async Task Workflow_ExecutorEvents_ProduceWorkflowActionItemsAsync()
{
// Arrange
var agent = new StreamingTextAgent("test-agent", "Result");
var workflow = AgentWorkflowBuilder.BuildSequential("test-actions", agent);
var workflowAgent = workflow.AsAIAgent(
id: "actions-workflow",
name: "Actions Workflow",
executionEnvironment: InProcessExecution.OffThread);
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
// Act
var events = await CollectEventsAsync(handler, request, context);
// Assert: workflow should produce OutputItemAdded events for executor lifecycle
var addedEvents = events.OfType<ResponseOutputItemAddedEvent>().ToList();
Assert.True(addedEvents.Count >= 1,
$"Expected at least 1 output item added event, got {addedEvents.Count}");
}
// ===== Keyed Workflow Registration =====
[Fact]
public async Task WorkflowAgent_RegisteredWithKey_ResolvesCorrectlyAsync()
{
// Arrange: workflow agent registered with a keyed service name
var agent = new StreamingTextAgent("inner", "Keyed workflow response");
var workflow = AgentWorkflowBuilder.BuildSequential("keyed-wf", agent);
var workflowAgent = workflow.AsAIAgent(
id: "keyed-workflow",
name: "Keyed Workflow",
executionEnvironment: InProcessExecution.OffThread);
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton("my-workflow", workflowAgent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference("my-workflow"));
request.Input = CreateUserInput("Test keyed workflow");
var mockContext = CreateMockContext();
// Act
var events = await CollectEventsAsync(handler, request, mockContext.Object);
// Assert
Assert.IsType<ResponseCreatedEvent>(events[0]);
Assert.True(events.Count >= 3, $"Expected at least 3 events, got {events.Count}");
}
// ===== OutputConverter Direct Workflow Pattern Tests =====
// These test the OutputConverter directly with update patterns that mirror real workflows.
[Fact]
public async Task OutputConverter_SequentialWorkflowPattern_ProducesCorrectEventsAsync()
{
// Simulate what WorkflowSession produces for a 2-agent sequential workflow
var (stream, _) = CreateTestStream();
var updates = new[]
{
// Superstep 1: Agent 1
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
new AgentResponseUpdate { MessageId = "msg_a1", Contents = [new MeaiTextContent("Agent 1 output")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
// Superstep 2: Agent 2
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") },
new AgentResponseUpdate { MessageId = "msg_a2", Contents = [new MeaiTextContent("Agent 2 output")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 4 workflow action items + 2 text messages = 6 output items
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task OutputConverter_GroupChatPattern_ProducesCorrectEventsAsync()
{
// Simulate round-robin group chat: agent1 → agent2 → agent1 → terminate
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
new AgentResponseUpdate { MessageId = "msg_gc_1", Contents = [new MeaiTextContent("Agent 1 turn 1")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_2", "turn") },
new AgentResponseUpdate { MessageId = "msg_gc_2", Contents = [new MeaiTextContent("Agent 2 turn 1")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_2", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(3) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
new AgentResponseUpdate { MessageId = "msg_gc_3", Contents = [new MeaiTextContent("Agent 1 turn 2")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(3) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 6 workflow actions + 3 text messages = 9 output items
Assert.Equal(9, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Equal(3, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task OutputConverter_CodeExecutorPattern_ProducesCorrectEventsAsync()
{
// Simulate a code-based FunctionExecutor: invoked → completed, no text content
// (code executors don't produce AgentResponseUpdateEvent, just executor lifecycle)
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("uppercase_fn", "hello") },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("uppercase_fn", "HELLO") },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
// Second executor uses the output
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("format_agent", "start") },
new AgentResponseUpdate { MessageId = "msg_fmt", Contents = [new MeaiTextContent("Formatted: HELLO")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("format_agent", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 4 workflow actions + 1 text message = 5 output items
Assert.Equal(5, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task OutputConverter_SubworkflowPattern_ProducesCorrectEventsAsync()
{
// Simulate a parent workflow that invokes a sub-workflow executor
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("parent") },
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
// Sub-workflow executor invoked
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("sub_workflow_host", "start") },
// Inner agent within sub-workflow produces text (unwrapped by WorkflowSession)
new AgentResponseUpdate { MessageId = "msg_sub_1", Contents = [new MeaiTextContent("Sub-workflow agent output")] },
// Sub-workflow executor completed
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("sub_workflow_host", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 2 workflow actions + 1 text message = 3 output items
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task OutputConverter_WorkflowWithMultipleContentTypes_HandlesAllCorrectlyAsync()
{
// Simulate a workflow producing reasoning, text, function calls, and usage
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("planner", "start") },
// Reasoning
new AgentResponseUpdate { Contents = [new TextReasoningContent("Let me think about this...")] },
// Function call (tool use)
new AgentResponseUpdate
{
Contents = [new FunctionCallContent("call_search", "web_search",
new Dictionary<string, object?> { ["query"] = "latest news" })]
},
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("planner", null) },
// Next executor uses tool result
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("writer", "start") },
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("Based on my research, ")] },
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("here are the findings.")] },
new AgentResponseUpdate
{
Contents = [new UsageContent(new UsageDetails { InputTokenCount = 500, OutputTokenCount = 200, TotalTokenCount = 700 })]
},
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("writer", null) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Workflow actions: 4 (2 invoked + 2 completed)
// Content: 1 reasoning + 1 function call + 1 text message = 3
// Total: 7 output items
Assert.Equal(7, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// ===== Helpers =====
private static (AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context)
CreateHandlerWithAgent(AIAgent agent, string userMessage)
{
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton(agent);
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = CreateUserInput(userMessage);
var mockContext = CreateMockContext();
return (handler, request, mockContext.Object);
}
private static BinaryData CreateUserInput(string text)
{
return BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_in_1", status = "completed", role = "user",
content = new[] { new { type = "input_text", text } }
}
});
}
private static Mock<ResponseContext> CreateMockContext()
{
var mock = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mock.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mock.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
return mock;
}
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
{
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
var stream = new ResponseEventStream(mockContext.Object, request);
return (stream, mockContext);
}
private static async Task<List<ResponseStreamEvent>> CollectEventsAsync(
AgentFrameworkResponseHandler handler,
CreateResponse request,
ResponseContext context)
{
var events = new List<ResponseStreamEvent>();
await foreach (var evt in handler.CreateAsync(request, context, CancellationToken.None))
{
events.Add(evt);
}
return events;
}
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
{
foreach (var item in source)
{
yield return item;
}
await Task.CompletedTask;
}
// ===== Test Agent Types =====
/// <summary>
/// A test agent that streams a single text update.
/// </summary>
private sealed class StreamingTextAgent(string id, string responseText) : AIAgent
{
public new string Id => id;
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
yield return new AgentResponseUpdate
{
MessageId = $"msg_{id}",
Contents = [new MeaiTextContent(responseText)]
};
await Task.CompletedTask;
}
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
/// <summary>
/// A test agent that always throws an exception during streaming.
/// </summary>
private sealed class ThrowingStreamingAgent(string id, Exception exception) : AIAgent
{
public new string Id => id;
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw exception;
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
}
@@ -1,14 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<NoWarn>$(NoWarn);NU1605;NU1903</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<PackageReference Include="Azure.AI.Projects" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
<PackageReference Include="Azure.AI.AgentServer.Responses" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
</ItemGroup>
<!-- Hosting tests only compile on .NET Core TFMs -->
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<Compile Remove="Hosting\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="FoundryEvalConverterTests.cs" />