diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml
index 6e4f08086f..8d20f5196a 100644
--- a/.github/workflows/dotnet-build-and-test.yml
+++ b/.github/workflows/dotnet-build-and-test.yml
@@ -351,7 +351,6 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
- targetFramework: net10.0
configuration: Release
steps:
- uses: actions/checkout@v6
@@ -368,31 +367,15 @@ jobs:
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- - name: Generate test solution (no samples)
- shell: pwsh
- run: |
- ./dotnet/eng/scripts/New-FilteredSolution.ps1 `
- -Solution dotnet/agent-framework-dotnet.slnx `
- -TargetFramework $env:targetFramework `
- -Configuration $env:configuration `
- -ExcludeSamples `
- -OutputPath dotnet/filtered.slnx `
- -Verbose
-
- - name: Generate Foundry hosted IT filtered solution
- shell: pwsh
- run: |
- ./dotnet/eng/scripts/New-FilteredSolution.ps1 `
- -Solution dotnet/filtered.slnx `
- -TargetFramework $env:targetFramework `
- -Configuration $env:configuration `
- -TestProjectNameFilter "Foundry.Hosting.IntegrationTests*" `
- -OutputPath dotnet/filtered-foundry-hosted.slnx `
- -Verbose
-
+ # Build the test csproj directly instead of a filtered slnx + -f override.
+ # The test project pins TargetFrameworks=net10.0 and its ProjectReference closure
+ # gives MSBuild a single-rooted graph, so each multi-targeted dependency is invoked
+ # exactly once for net10.0. This avoids the MSB3026/MSB3491/MSB4018/MSB3883 file-lock
+ # collisions caused by parallel inner-builds racing on shared bin/obj output paths
+ # under the previous slnx + global TFM override approach.
- name: Build Foundry hosted IT (and its deps)
shell: bash
- run: dotnet build dotnet/filtered-foundry-hosted.slnx -c "$configuration" -f "$targetFramework" --warnaserror
+ run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror
- name: Azure CLI Login
uses: azure/login@v2
@@ -405,13 +388,12 @@ jobs:
# are picked up; the image tag is content-hashed across the test container source AND its
# framework project references, so identical content is a no-op push.
#
- # `-UsePrebuiltProjectReferences` opts into the no-rebuild fast path: publish skips
- # rebuilding ProjectReferences and consumes the DLLs the prior "Build Foundry hosted IT
- # (and its deps)" step already produced. This avoids MSB3026 ("file is being used by
- # another process") collisions caused by the previous build's shared-compilation server
- # still holding file handles to those DLLs. Safe in CI because the prebuild step ran in
- # the same job against the same source. Do not remove the prebuild step (the subsequent
- # `dotnet test --no-build` step depends on it too).
+ # The script always passes --no-dependencies to dotnet publish so publish never re-touches
+ # the framework lib DLLs the prior "Build Foundry hosted IT (and its deps)" step produced.
+ # This structurally eliminates the MSB3026 collision that VBCSCompiler from the prebuild
+ # would otherwise cause by holding file handles to those DLLs. Do not remove the prebuild
+ # step: the subsequent `dotnet test --no-build` step and the publish's ProjectReference
+ # resolution both depend on the prebuilt outputs being present.
- name: Build and push Foundry Hosted Agents test container
id: build-foundry-hosted-image
shell: pwsh
@@ -421,14 +403,13 @@ jobs:
if ([string]::IsNullOrWhiteSpace($registry)) {
throw "IT_HOSTED_AGENT_REGISTRY not set in the integration environment."
}
- & "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry -UsePrebuiltProjectReferences | Tee-Object -FilePath $env:GITHUB_ENV -Append
+ & "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry | Tee-Object -FilePath $env:GITHUB_ENV -Append
- name: Run Foundry Hosted Agents Integration Tests
shell: pwsh
working-directory: dotnet
run: |
- dotnet test --solution ./filtered-foundry-hosted.slnx `
- -f $env:targetFramework `
+ dotnet test --project tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj `
-c $env:configuration `
--no-build -v Normal `
--report-xunit-trx `
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 173eb4793b..38df2e4931 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -25,7 +25,7 @@
-
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 01c28beef8..d58a691599 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -313,6 +313,9 @@
+
+
+
@@ -332,6 +335,7 @@
+
diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf
index 00e3e6571e..14d5262533 100644
--- a/dotnet/agent-framework-release.slnf
+++ b/dotnet/agent-framework-release.slnf
@@ -30,7 +30,8 @@
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
"src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj",
- "src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj"
+ "src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj",
+ "src\\Microsoft.Agents.AI.Hyperlight\\Microsoft.Agents.AI.Hyperlight.csproj"
]
}
}
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs
index 18f4eccd5f..ff60ac5aaf 100644
--- a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs
+++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs
@@ -8,6 +8,11 @@
// even if the process is interrupted mid-loop, but may also result in chat history that is not
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
//
+// Additionally, this sample demonstrates the MessageInjectingChatClient feature, which allows tool
+// code to inject new user messages during the function execution loop. When a tool or anything else enqueues
+// a message via MessageInjectingChatClient.EnqueueMessages during the tool execution loop, the PerServiceCallChatHistoryPersistingChatClient
+// detects the pending message before the next service call and includes the injected message in the request.
+//
// To use end-of-run persistence instead (atomic run semantics), remove the
// RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run
// persistence is the default behavior.
@@ -54,6 +59,37 @@ static string GetTime([Description("The city name.")] string city) =>
_ => $"{city}: time data not available."
};
+// This tool demonstrates message injection during the function execution loop.
+// When called, it checks travel advisories for a city. If an advisory is active, it uses
+// the ambient run context to resolve MessageInjectingChatClient and injects a follow-up user message
+// asking for alternative destinations. The model will process this injected message on the next
+// service call — even though the parent FunctionInvokingChatClient loop would otherwise stop.
+[Description("Check current travel advisories for a city.")]
+static string CheckTravelAdvisory([Description("The city name.")] string city)
+{
+ // Simulated travel advisory data.
+ var advisory = city.ToUpperInvariant() switch
+ {
+ "LONDON" => "Travel advisory: Severe fog warnings in London. Flights may be delayed or cancelled.",
+ "SEATTLE" => "Travel advisory: Heavy rainfall expected. Flooding possible in low-lying areas.",
+ _ => null
+ };
+
+ if (advisory is null)
+ {
+ return $"{city}: No active travel advisories.";
+ }
+
+ // When an advisory is found, inject a follow-up question so the model automatically
+ // suggests alternatives without the user needing to ask.
+ var runContext = AIAgent.CurrentRunContext!;
+ runContext.Agent.GetService()?.EnqueueMessages(
+ runContext.Session!,
+ [new ChatMessage(ChatRole.User, $"Given the travel advisory for {city}, what alternative cities would you recommend instead?")]);
+
+ return advisory;
+}
+
// Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence.
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
@@ -65,10 +101,11 @@ AIAgent agent = chatClient.AsAIAgent(
{
Name = "WeatherAssistant",
RequirePerServiceCallChatHistoryPersistence = true,
+ EnableMessageInjection = true,
ChatOptions = new()
{
- Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
- Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime)]
+ Instructions = "You are a helpful travel assistant. When asked about cities, call the appropriate tools for each city.",
+ Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime), AIFunctionFactory.Create(CheckTravelAdvisory)]
},
});
@@ -109,6 +146,18 @@ async Task RunNonStreamingAsync()
response = await agent.RunAsync(FollowUp2, session);
PrintAgentResponse(response.Text);
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
+
+ // Fourth turn — demonstrates message injection during the function loop.
+ // The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up
+ // user message asking for alternative cities. After the tool completes, the internal loop
+ // in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message
+ // and calls the service again, so the model answers the follow-up automatically.
+ const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories.";
+ PrintUserMessage(TravelPrompt);
+
+ response = await agent.RunAsync(TravelPrompt, session);
+ PrintAgentResponse(response.Text);
+ PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId);
}
async Task RunStreamingAsync()
@@ -181,6 +230,30 @@ async Task RunStreamingAsync()
Console.WriteLine();
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
+
+ // Fourth turn — demonstrates message injection during the function loop (streaming).
+ // The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up
+ // user message asking for alternative cities. After the tool completes, the internal loop
+ // in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message
+ // and calls the service again, so the model answers the follow-up automatically.
+ const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories.";
+ PrintUserMessage(TravelPrompt);
+
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[Agent] ");
+ Console.ResetColor();
+
+ await foreach (var update in agent.RunStreamingAsync(TravelPrompt, session))
+ {
+ Console.Write(update);
+
+ // During streaming we should be able to see updates to the chat history
+ // before the full run completes, as each service call is made and persisted.
+ PrintChatHistory(session, "During travel advisory run", ref lastChatHistorySize, ref lastConversationId);
+ }
+
+ Console.WriteLine();
+ PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId);
}
void PrintUserMessage(string message)
diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj
index 0db6ba9fe6..a2787b4130 100644
--- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj
+++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj
@@ -1,4 +1,4 @@
-
+Exe
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.dockerignore b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.dockerignore
new file mode 100644
index 0000000000..cf85b06faa
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.dockerignore
@@ -0,0 +1,6 @@
+**/bin
+**/obj
+**/.vs
+**/.vscode
+.env
+*.user
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example
new file mode 100644
index 0000000000..b8fe9e8e7a
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example
@@ -0,0 +1,5 @@
+AZURE_AI_PROJECT_ENDPOINT=
+ASPNETCORE_URLS=http://+:8088
+ASPNETCORE_ENVIRONMENT=Development
+AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
+AZURE_BEARER_TOKEN=DefaultAzureCredential
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile
new file mode 100644
index 0000000000..82f5e1b85c
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile
@@ -0,0 +1,17 @@
+# Use the official .NET 10.0 ASP.NET runtime as a parent image
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
+WORKDIR /app
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+COPY . .
+RUN dotnet restore
+RUN dotnet publish -c Release -o /app/publish
+
+# Final stage
+FROM base AS final
+WORKDIR /app
+COPY --from=build /app/publish .
+EXPOSE 8088
+ENV ASPNETCORE_URLS=http://+:8088
+ENTRYPOINT ["dotnet", "HostedFiles.dll"]
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile.contributor
new file mode 100644
index 0000000000..7a34f9361d
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile.contributor
@@ -0,0 +1,19 @@
+# Dockerfile for contributors building from the agent-framework repository source.
+#
+# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
+# which means a standard multi-stage Docker build cannot resolve dependencies outside
+# this folder. Instead, pre-publish the app targeting the container runtime and copy
+# the output into the container:
+#
+# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
+# docker build -f Dockerfile.contributor -t hosted-files .
+# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-files -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-files
+#
+# For end-users consuming the NuGet package (not ProjectReference), use the standard
+# Dockerfile which performs a full dotnet restore + publish inside the container.
+FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
+WORKDIR /app
+COPY out/ .
+EXPOSE 8088
+ENV ASPNETCORE_URLS=http://+:8088
+ENTRYPOINT ["dotnet", "HostedFiles.dll"]
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj
new file mode 100644
index 0000000000..fe36ca8ba2
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj
@@ -0,0 +1,40 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ HostedFiles
+ HostedFiles
+ $(NoWarn);
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs
new file mode 100644
index 0000000000..aba8f4ebef
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs
@@ -0,0 +1,223 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// Hosted Files Agent - A hosted agent that exposes two distinct file knowledge sources
+// through scoped, security-hardened tools:
+//
+// * Bundled files (image-baked) — files copied into the published output via the csproj
+// rule. Live at /app/resources/ inside the container.
+// Author-shipped knowledge that ships with every session.
+//
+// * Session files (per-session $HOME volume) — files uploaded at runtime via the alpha
+// Azure.AI.Projects.AgentSessionFiles SDK. Live at $HOME inside the per-session
+// container, which the platform sets to /home/session by default
+// (container-image-spec.md line 127, "If you use the session files API, $HOME is
+// also the base path for those operations").
+//
+// Each source is exposed via a separate tool pair, each rooted at its own directory.
+// Tools take a fileName, not a path: Path.GetFileName strips any directory components,
+// then a canonicalize + StartsWith(root) check enforces the boundary. The model cannot
+// be tricked into reading /etc/passwd or any path outside its tool's root, even via
+// indirect prompt injection in an uploaded file.
+//
+// Required environment variables:
+// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
+// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
+//
+// Optional:
+// AGENT_NAME - Agent name (default: hosted-files)
+// BUNDLED_FILES_DIR - Override the bundled-files root
+// (default: /resources, i.e. /app/resources/)
+// HOME - Standard env var; the per-session sandbox volume
+// (default: /home/session in the platform-managed container)
+
+using System.ComponentModel;
+using Azure.AI.Projects;
+using Azure.Core;
+using Azure.Identity;
+using DotNetEnv;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Foundry.Hosting;
+using Microsoft.Extensions.AI;
+
+// Load .env file if present (for local development)
+Env.TraversePath().Load();
+
+// Bypass SampleEnvironment alias (which prompts on missing env vars) for optional values.
+string? GetOptionalEnv(string key) => System.Environment.GetEnvironmentVariable(key);
+
+string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
+ ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
+string deploymentName = GetOptionalEnv("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
+
+// Use a chained credential: try a temporary dev token first (for local Docker debugging),
+// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
+TokenCredential credential = new ChainedTokenCredential(
+ new DevTemporaryTokenCredential(),
+ new DefaultAzureCredential());
+
+// ── File roots (canonicalized once) ──────────────────────────────────────────
+
+// Bundled root: where csproj lands at runtime.
+// In the container that resolves to /app/resources/.
+string bundledRoot = Path.GetFullPath(
+ GetOptionalEnv("BUNDLED_FILES_DIR")
+ ?? Path.Combine(AppContext.BaseDirectory, "resources"));
+
+// Session root: the per-session $HOME volume mounted by the Foundry platform.
+// Files uploaded via AgentSessionFiles.UploadSessionFileAsync(sessionStoragePath: "foo")
+// land at $HOME/foo per container-image-spec.md line 172.
+string sessionRoot = Path.GetFullPath(
+ GetOptionalEnv("HOME")
+ ?? "/home/session");
+
+// ── Tools: bundled files (image-baked, /app/resources/) ──────────────────────
+
+[Description("List the names of files bundled with the agent (built-in knowledge that ships with the image).")]
+string ListBundledFiles() => SafeListNames(bundledRoot);
+
+[Description("Read the full text contents of a bundled file by name. Bundled files are built-in knowledge shipped with the agent image.")]
+string ReadBundledFile(
+ [Description("Name of the bundled file (no directory components). Must be one of the names returned by ListBundledFiles.")] string fileName)
+ => SafeRead(bundledRoot, fileName, scope: "bundled files");
+
+// ── Tools: session files (per-session $HOME) ─────────────────────────────────
+
+[Description("List the names of files uploaded into the current session sandbox by the user (e.g., via AgentSessionFiles.UploadSessionFileAsync).")]
+string ListSessionFiles() => SafeListNames(sessionRoot);
+
+[Description("Read the full text contents of a file uploaded into the current session by name. Session files are user-supplied data that lives only for the lifetime of this session.")]
+string ReadSessionFile(
+ [Description("Name of the session file (no directory components). Must be one of the names returned by ListSessionFiles.")] string fileName)
+ => SafeRead(sessionRoot, fileName, scope: "session files");
+
+// ── Path-safe helpers (defense-in-depth: GetFileName + canonicalize + StartsWith(root)) ──
+
+string SafeListNames(string root)
+{
+ try
+ {
+ if (!Directory.Exists(root))
+ {
+ return string.Empty;
+ }
+
+ return string.Join(
+ Environment.NewLine,
+ Directory.EnumerateFiles(root).Select(Path.GetFileName));
+ }
+ catch (Exception ex)
+ {
+ return $"Error listing files: {ex.Message}";
+ }
+}
+
+string SafeRead(string root, string fileName, string scope)
+{
+ try
+ {
+ // Step 1: strip any directory components the model might have included.
+ string safeName = Path.GetFileName(fileName);
+ if (string.IsNullOrEmpty(safeName))
+ {
+ return $"File '{fileName}' not found in {scope}.";
+ }
+
+ // Step 2: combine with the root and canonicalize.
+ string fullPath = Path.GetFullPath(Path.Combine(root, safeName));
+
+ // Step 3: enforce the prefix boundary so a crafted name still cannot escape.
+ string rootPrefix = root.EndsWith(Path.DirectorySeparatorChar)
+ ? root
+ : root + Path.DirectorySeparatorChar;
+ if (!fullPath.StartsWith(rootPrefix, StringComparison.Ordinal))
+ {
+ return $"File '{fileName}' not found in {scope}.";
+ }
+
+ return File.Exists(fullPath)
+ ? File.ReadAllText(fullPath)
+ : $"File '{fileName}' not found in {scope}.";
+ }
+ catch (Exception ex)
+ {
+ return $"Error reading '{fileName}': {ex.Message}";
+ }
+}
+
+// ── Create and host the agent ────────────────────────────────────────────────
+
+AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
+ .AsAIAgent(
+ model: deploymentName,
+ instructions: """
+ You are a friendly assistant that answers questions over two file sources:
+
+ - Bundled files: built-in knowledge that ships with the agent image
+ (e.g., reference reports the author packaged with you). Tools:
+ ListBundledFiles, ReadBundledFile.
+
+ - Session files: user-uploaded data for this session only (e.g., a CSV
+ the user wants you to analyse). Tools: ListSessionFiles, ReadSessionFile.
+
+ Pick the tool pair by intent. If a name could match either source, list
+ both first. Always read the file before answering; do not guess. Quote
+ numbers and figures verbatim from the file.
+ """,
+ name: GetOptionalEnv("AGENT_NAME") ?? "hosted-files",
+ description: "Hosted agent that answers questions over bundled (image-baked) and session-uploaded files via two scoped tool pairs.",
+ tools:
+ [
+ AIFunctionFactory.Create(ListBundledFiles),
+ AIFunctionFactory.Create(ReadBundledFile),
+ AIFunctionFactory.Create(ListSessionFiles),
+ AIFunctionFactory.Create(ReadSessionFile),
+ ]);
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddFoundryResponses(agent);
+
+var app = builder.Build();
+app.MapFoundryResponses();
+
+if (app.Environment.IsDevelopment())
+{
+ app.MapFoundryResponses("openai/v1");
+}
+
+app.Run();
+
+///
+/// A for local Docker debugging only.
+/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable
+/// once at startup. This should NOT be used in production.
+///
+/// Generate a token on your host and pass it to the container:
+/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
+/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
+///
+internal sealed class DevTemporaryTokenCredential : TokenCredential
+{
+ private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
+ private readonly string? _token;
+
+ public DevTemporaryTokenCredential()
+ {
+ this._token = System.Environment.GetEnvironmentVariable(EnvironmentVariable);
+ }
+
+ public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ => this.GetAccessToken();
+
+ public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ => new(this.GetAccessToken());
+
+ private AccessToken GetAccessToken()
+ {
+ if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
+ {
+ throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
+ }
+
+ return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
+ }
+}
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md
new file mode 100644
index 0000000000..729aca5c5f
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md
@@ -0,0 +1,128 @@
+# Hosted-Files
+
+A hosted agent that demonstrates **two distinct file knowledge sources** through scoped, security-hardened tools:
+
+- **Bundled files** (image-baked) — files the author packages with the agent at build time. Live at `/app/resources/` inside the container, copied from this project's [`resources/`](./resources/) folder via the csproj `` rule.
+- **Session files** (per-session `$HOME` volume) — files the user uploads at runtime via the alpha `Azure.AI.Projects.AgentSessionFiles` SDK. Live at `$HOME` inside the per-session container. The Foundry platform sets `HOME=/home/session` by default and roots the session-files API there per [`container-image-spec.md` line 172](https://github.com/microsoft/foundrysdk-specs/blob/main/specs/agents/hosted_agents/container-spec/docs/container-image-spec.md): *"If you use the session files API, `$HOME` is also the base path for those operations; any paths given in those API endpoints will be relative to `$HOME`."*
+
+## Tool surface
+
+Each source is exposed via its own tool pair, rooted at its own directory. The model picks by intent.
+
+| Tool | Source | Root |
+|------|--------|------|
+| `ListBundledFiles` | Bundled (image-baked) | `/app/resources/` |
+| `ReadBundledFile` | Bundled (image-baked) | `/app/resources/` |
+| `ListSessionFiles` | Session-uploaded | `$HOME` (`/home/session`) |
+| `ReadSessionFile` | Session-uploaded | `$HOME` (`/home/session`) |
+
+## Security model — distinct tools, distinct sandboxes
+
+Each tool takes a `fileName` (no directory components allowed) and enforces three layers of defence inside the implementation:
+
+1. **`Path.GetFileName(input)`** strips any directory parts from the model-supplied name. `"../../etc/passwd"` becomes `"passwd"`.
+2. **`Path.GetFullPath(Combine(root, name))`** canonicalises the path.
+3. **`fullPath.StartsWith(root + DirectorySeparatorChar)`** rejects anything that resolves outside the tool's root.
+
+Failures return a controlled `"File '' not found in ."` rather than throwing or exposing the canonical path.
+
+This is why the agent has four narrowly-scoped tools instead of a single `ReadFile(path)`:
+
+- **Smaller per-tool attack surface.** Each tool has one purpose, one root, and no path-typed parameter. Even a buggy implementation can only leak its own directory.
+- **Cross-boundary access is impossible by schema.** A prompt-injection attempt to make the bundled tool read a session path (or vice versa) does not even compile in the tool schema the model sees.
+- **Read-only, non-recursive listing.** No write tools, no glob, no `..`.
+
+## Companion
+
+[`Using-Samples/SessionFilesClient`](../Using-Samples/SessionFilesClient/) — a thin chat REPL (same shape as [`SimpleAgent`](../Using-Samples/SimpleAgent/)) that points at the deployed Hosted-Files endpoint via `FoundryAgent` and lets you ask questions whose answers come from either file source.
+
+## Live proof of the session-files contract
+
+The end-to-end alpha-SDK round trip (client uploads via `AgentSessionFiles.UploadSessionFileAsync` → file arrives at `$HOME/` inside the per-session container → agent's `ReadSessionFile` tool reads it → response quotes the verbatim contents) is exercised live by [`SessionFilesHostedAgentTests.UploadedFile_IsReadByHostedAgentAsync`](../../../../../tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs) against the matching `session-files` scenario in the integration test container.
+
+## Prerequisites
+
+- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
+- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
+- Azure CLI logged in (`az login`)
+
+## Configuration
+
+Copy the template and fill in your project endpoint:
+
+```bash
+cp .env.example .env
+```
+
+Edit `.env`:
+
+```env
+AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/
+ASPNETCORE_URLS=http://+:8088
+ASPNETCORE_ENVIRONMENT=Development
+AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
+```
+
+> `.env` is gitignored. The `.env.example` template is checked in as a reference.
+
+## Running directly (contributors)
+
+```bash
+cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files
+AGENT_NAME=hosted-files dotnet run
+```
+
+The agent starts on `http://localhost:8088`.
+
+## Try it from the SessionFilesClient REPL
+
+### Bundled files (works against any deployment, including local)
+
+```bash
+cd ../Using-Samples/SessionFilesClient
+$env:AGENT_ENDPOINT = "http://localhost:8088"
+$env:AGENT_NAME = "hosted-files"
+dotnet run
+
+You> What is the total revenue in the contoso file?
+Agent> The contoso file reports total revenue of "$1,482.6M".
+```
+
+The agent calls `ListBundledFiles`, sees `contoso_q1_2026_report.txt`, calls `ReadBundledFile("contoso_q1_2026_report.txt")` (which resolves under `/app/resources/`), and quotes the figure verbatim.
+
+### Session files (against a deployed agent)
+
+Upload a file to a specific session via `azd ai agent files upload` or via the alpha `AgentSessionFiles` SDK (see the integration test for the SDK call), then ask the agent about it. The agent's `ReadSessionFile` tool reads from `$HOME` and surfaces the content the same way.
+
+## Running with Docker
+
+This project uses `ProjectReference`, so use `Dockerfile.contributor` which takes a pre-published output:
+
+```bash
+dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
+docker build -f Dockerfile.contributor -t hosted-files .
+
+export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
+docker run --rm -p 8088:8088 \
+ -e AGENT_NAME=hosted-files \
+ -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
+ --env-file .env \
+ hosted-files
+```
+
+The bundled `resources/` folder is part of the published output and ships inside the image.
+
+## NuGet package users
+
+If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor` and switch the `ProjectReference` entries in `HostedFiles.csproj` to `PackageReference` (commented section in the csproj).
+
+## Adding more bundled files
+
+Drop additional text files into [`resources/`](./resources/). The csproj `` rule picks them up on the next `dotnet build` / `docker build`.
+
+## Overrides
+
+| Env var | Purpose | Default |
+|---------|---------|---------|
+| `BUNDLED_FILES_DIR` | Override the bundled-files root the tools read from. | `/resources` (`/app/resources/` in container) |
+| `HOME` | The per-session sandbox volume root the session-files tools read from. Set by the Foundry platform; can be overridden for local testing. | `/home/session` |
\ No newline at end of file
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml
new file mode 100644
index 0000000000..cda1ba6494
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml
@@ -0,0 +1,30 @@
+# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
+name: hosted-files
+displayName: "Hosted Files Agent"
+
+description: >
+ A hosted agent that answers questions over a small set of files bundled
+ with its container image (under /app/resources/). Two local C# function
+ tools (ListFiles, ReadFile) surface the bundled file contents to the model.
+
+metadata:
+ tags:
+ - AI Agent Hosting
+ - Azure AI AgentServer
+ - Responses Protocol
+ - Bundled Files
+ - Local Tools
+ - Agent Framework
+
+template:
+ name: hosted-files
+ kind: hosted
+ protocols:
+ - protocol: responses
+ version: 1.0.0
+ resources:
+ cpu: "0.25"
+ memory: 0.5Gi
+parameters:
+ properties: []
+resources: []
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml
new file mode 100644
index 0000000000..f949ac09ee
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml
@@ -0,0 +1,9 @@
+# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
+kind: hosted
+name: hosted-files
+protocols:
+ - protocol: responses
+ version: 1.0.0
+resources:
+ cpu: "0.25"
+ memory: 0.5Gi
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/resources/contoso_q1_2026_report.txt b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/resources/contoso_q1_2026_report.txt
new file mode 100644
index 0000000000..858192a7d3
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/resources/contoso_q1_2026_report.txt
@@ -0,0 +1,121 @@
+Contoso Corporation
+Quarterly Report — Q1 2026 (Three months ended March 31, 2026)
+
+DISCLAIMER
+This document contains fictional data for sample/demo purposes only.
+Contoso is a fictional company; all figures below are fabricated.
+
+------------------------------------------------------------
+1. EXECUTIVE SUMMARY
+------------------------------------------------------------
+Contoso delivered a solid first quarter, with total revenue of
+$1,482.6M, up 11.4% year-over-year. Growth was led by the Cloud
+Services segment (+22.7% YoY) and continued double-digit expansion
+in International markets. Operating margin expanded 140 basis points
+to 23.8% on disciplined cost management and improved gross margin.
+
+Key highlights:
+ - Revenue: $1,482.6M (YoY +11.4%)
+ - Gross profit: $912.0M (gross margin 61.5%)
+ - Operating income: $352.9M (operating margin 23.8%)
+ - Net income: $268.4M (net margin 18.1%)
+ - Diluted EPS: $1.27 (vs. $1.04 prior year)
+ - Free cash flow: $311.5M
+ - Cash & equivalents: $2,140.8M
+
+------------------------------------------------------------
+2. INCOME STATEMENT (USD millions, unaudited)
+------------------------------------------------------------
+ Q1 2026 Q1 2025 YoY %
+Revenue 1,482.6 1,330.7 +11.4%
+Cost of revenue 570.6 538.9 +5.9%
+Gross profit 912.0 791.8 +15.2%
+ Gross margin 61.5% 59.5% +200 bps
+Operating expenses
+ Research & development 241.4 220.5 +9.5%
+ Sales & marketing 218.7 205.1 +6.6%
+ General & administrative 99.0 88.6 +11.7%
+Total operating expenses 559.1 514.2 +8.7%
+Operating income 352.9 277.6 +27.1%
+ Operating margin 23.8% 20.9% +290 bps
+Other income / (expense), net 8.4 5.1
+Income before taxes 361.3 282.7
+Provision for income taxes 92.9 72.6
+Net income 268.4 210.1 +27.7%
+Diluted EPS (USD) 1.27 1.04 +22.1%
+
+------------------------------------------------------------
+3. REVENUE BY SEGMENT (USD millions)
+------------------------------------------------------------
+Segment Q1 2026 Q1 2025 YoY %
+Cloud Services 612.4 499.1 +22.7%
+Productivity Software 448.9 422.6 +6.2%
+Devices & Hardware 267.0 260.4 +2.5%
+Professional Services 154.3 148.6 +3.8%
+Total revenue 1,482.6 1,330.7 +11.4%
+
+------------------------------------------------------------
+4. REVENUE BY GEOGRAPHY (USD millions)
+------------------------------------------------------------
+Region Q1 2026 Q1 2025 YoY %
+North America 812.1 756.0 +7.4%
+EMEA 388.5 340.2 +14.2%
+Asia-Pacific 221.7 183.4 +20.9%
+Latin America 60.3 51.1 +18.0%
+Total revenue 1,482.6 1,330.7 +11.4%
+
+------------------------------------------------------------
+5. SELECTED BALANCE SHEET ITEMS (USD millions)
+------------------------------------------------------------
+ Mar 31, Dec 31,
+ 2026 2025
+Cash & equivalents 2,140.8 1,902.3
+Short-term investments 845.6 820.4
+Accounts receivable, net 1,012.7 988.5
+Total current assets 4,510.2 4,190.6
+Goodwill & intangibles 2,330.1 2,338.9
+Total assets 9,884.5 9,512.0
+Total current liabilities 2,118.4 2,054.7
+Long-term debt 1,750.0 1,750.0
+Total liabilities 4,402.6 4,310.5
+Total stockholders' equity 5,481.9 5,201.5
+
+------------------------------------------------------------
+6. CASH FLOW HIGHLIGHTS (USD millions)
+------------------------------------------------------------
+ Q1 2026 Q1 2025
+Net cash from operating activities 382.0 298.7
+Capital expenditures (70.5) (62.1)
+Free cash flow 311.5 236.6
+Share repurchases (120.0) (90.0)
+Dividends paid (54.2) (48.6)
+
+------------------------------------------------------------
+7. KEY OPERATING METRICS
+------------------------------------------------------------
+Cloud paid seats (millions) 48.6 39.7 +22.4%
+Cloud net revenue retention 118% 114%
+Active enterprise customers 18,420 16,905 +9.0%
+Headcount (end of period) 22,140 20,610 +7.4%
+
+------------------------------------------------------------
+8. OUTLOOK — Q2 2026 GUIDANCE
+------------------------------------------------------------
+Revenue: $1,520M – $1,560M (YoY +10% to +13%)
+Operating margin: 23.5% – 24.5%
+Diluted EPS: $1.30 – $1.36
+Capital expenditures: ~$80M
+
+Management remains confident in the full-year plan and reiterates
+fiscal-year 2026 revenue growth of 10–12% and operating-margin
+expansion of 100–150 basis points versus FY 2025.
+
+------------------------------------------------------------
+9. NOTES
+------------------------------------------------------------
+- All figures are unaudited and rounded to one decimal place.
+- Year-over-year comparisons are versus the same period in 2025.
+- "Free cash flow" is defined as net cash from operating activities
+ less capital expenditures, and is a non-GAAP measure.
+- This sample report is intended solely for demonstration of an
+ agent-driven document analysis pipeline.
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj
index 8871ea5242..366894856c 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj
@@ -1,4 +1,4 @@
-
+net10.0
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj
index 359e8a35bb..270a3f7391 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj
@@ -1,4 +1,4 @@
-
+net10.0
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj
index 31dafe2280..0029f66e39 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj
@@ -1,4 +1,4 @@
-
+net10.0
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj
index ed24c32eea..a0138c710a 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj
@@ -1,4 +1,4 @@
-
+net10.0
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj
index 458518c67b..e54d2f1b37 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj
index 75d012413d..57c5852455 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj
@@ -1,4 +1,4 @@
-
+net10.0
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs
new file mode 100644
index 0000000000..e05e025c0d
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs
@@ -0,0 +1,116 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.ClientModel.Primitives;
+using Azure.AI.Extensions.OpenAI;
+using Azure.AI.Projects;
+using Azure.Identity;
+using DotNetEnv;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Foundry;
+
+// Load .env file if present (for local development)
+Env.TraversePath().Load();
+
+Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT")
+ ?? "http://localhost:8088");
+
+var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
+ ?? throw new InvalidOperationException("AGENT_NAME is not set.");
+
+// ── Create an agent-framework agent backed by the remote Hosted-Files agent ──
+
+var options = new AIProjectClientOptions();
+
+if (agentEndpoint.Scheme == "http")
+{
+ // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
+ // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
+ // before the request hits the wire.
+
+ agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
+ options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
+}
+
+var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options);
+FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName));
+
+AgentSession session = await agent.CreateSessionAsync();
+
+// ── REPL ──────────────────────────────────────────────────────────────────────
+
+Console.ForegroundColor = ConsoleColor.Cyan;
+Console.WriteLine($"""
+ ══════════════════════════════════════════════════════════
+ Session Files Client
+ Connected to: {agentEndpoint}
+ Try: "Give me the total revenue in the contoso file."
+ Type a message or 'quit' to exit
+ ══════════════════════════════════════════════════════════
+ """);
+Console.ResetColor();
+Console.WriteLine();
+
+while (true)
+{
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.Write("You> ");
+ Console.ResetColor();
+
+ string? input = Console.ReadLine();
+
+ if (string.IsNullOrWhiteSpace(input)) { continue; }
+ if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; }
+
+ try
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.Write("Agent> ");
+ Console.ResetColor();
+
+ await foreach (var update in agent.RunStreamingAsync(input, session))
+ {
+ Console.Write(update);
+ }
+
+ Console.WriteLine();
+ }
+ catch (Exception ex)
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.WriteLine($"Error: {ex.Message}");
+ Console.ResetColor();
+ }
+
+ Console.WriteLine();
+}
+
+Console.WriteLine("Goodbye!");
+
+///
+/// For Local Development Only
+/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient
+/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check.
+///
+internal sealed class HttpSchemeRewritePolicy : PipelinePolicy
+{
+ public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ RewriteScheme(message);
+ ProcessNext(message, pipeline, currentIndex);
+ }
+
+ public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ RewriteScheme(message);
+ await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
+ }
+
+ private static void RewriteScheme(PipelineMessage message)
+ {
+ var uri = message.Request.Uri!;
+ if (uri.Scheme == Uri.UriSchemeHttps)
+ {
+ message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri;
+ }
+ }
+}
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md
new file mode 100644
index 0000000000..dbf9262ae8
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md
@@ -0,0 +1,50 @@
+# SessionFilesClient
+
+A thin chat REPL that connects to a deployed [`Hosted-Files`](../../Hosted-Files/) agent via `FoundryAgent` and lets you ask questions whose answers come from the files bundled with that agent. Same shape as [`SimpleAgent`](../SimpleAgent/) — point it at an `AGENT_ENDPOINT`, build a `FoundryAgent`, run.
+
+The agent's container-side `ListFiles` and `ReadFile` tools surface the bundled file contents to the model. The client knows nothing about files; that is entirely the agent's concern.
+
+## Prerequisites
+
+- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
+- A running [`Hosted-Files`](../../Hosted-Files/) agent (locally via `dotnet run` or deployed to Foundry)
+- Azure CLI logged in (`az login`)
+
+## Configuration
+
+```env
+AGENT_ENDPOINT=http://localhost:8088
+AGENT_NAME=hosted-files
+```
+
+`AGENT_ENDPOINT` defaults to `http://localhost:8088`. Override with the deployed agent endpoint when chatting against Foundry.
+
+## Run
+
+```bash
+cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient
+$env:AGENT_ENDPOINT = "http://localhost:8088"
+$env:AGENT_NAME = "hosted-files"
+dotnet run
+```
+
+## End-to-end demo
+
+With the [`Hosted-Files`](../../Hosted-Files/) agent running:
+
+```text
+══════════════════════════════════════════════════════════
+Session Files Client
+Connected to: http://localhost:8088/
+Try: "Give me the total revenue in the contoso file."
+Type a message or 'quit' to exit
+══════════════════════════════════════════════════════════
+
+You> Give me the total revenue in the contoso file.
+Agent> The contoso file reports total revenue of "$1,482.6M".
+
+You> quit
+Goodbye!
+```
+
+The agent looked at its bundled files via `ListFiles`, picked `contoso_q1_2026_report.txt`, called `ReadFile`, and quoted the figure verbatim. The client only sent a chat prompt.
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj
new file mode 100644
index 0000000000..954036ba3b
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+ SessionFilesClient
+ session-files-client
+ $(NoWarn);NU1605;OPENAI001
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj
index 209edd9a82..71d9af8f71 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj
@@ -1,4 +1,4 @@
-
+$(TargetFrameworksCore)
@@ -31,7 +31,7 @@
-
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
index db66b30a88..6174e1b149 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
@@ -2,6 +2,7 @@
using System;
using System.ClientModel;
+using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
@@ -38,7 +39,28 @@ namespace Microsoft.Agents.AI.Foundry;
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FoundryAgent : DelegatingAIAgent
{
- private readonly AIProjectClient _aiProjectClient;
+ ///
+ /// Default OAuth scope for the Azure AI resource. Matches the scope used by
+ /// Azure.AI.Extensions.OpenAI's internal authentication helper so the bearer token is
+ /// accepted by the Foundry control plane.
+ ///
+ private const string AzureAiResourceScope = "https://ai.azure.com/.default";
+
+ ///
+ /// The cached when one was supplied or constructed by the active
+ /// constructor. Null when the agent was constructed via the agent-endpoint constructor, which
+ /// does not build a full .
+ ///
+ private readonly AIProjectClient? _aiProjectClient;
+
+ ///
+ /// Project-scoped . Always non-null. Used for project-level
+ /// operations such as .
+ /// In agent-endpoint mode this is built directly from the project root derived from the
+ /// supplied agent endpoint; in project-endpoint mode it is the cached client returned by
+ /// .
+ ///
+ private readonly ProjectOpenAIClient _projectOpenAIClient;
///
/// Initializes a new instance of the class using the direct Responses API path.
@@ -72,30 +94,49 @@ public sealed class FoundryAgent : DelegatingAIAgent
out var aiProjectClient))
{
this._aiProjectClient = aiProjectClient;
+ this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
}
///
/// Initializes a new instance of the class from an agent-specific endpoint.
///
- /// The agent-specific endpoint URI (must contain the agent name in the path).
+ ///
+ /// The agent-specific endpoint URI. Must be of the shape
+ /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai.
+ ///
/// The authentication credential.
- /// Optional configuration options for the .
+ ///
+ /// Optional configuration for the underlying . When supplied:
+ ///
+ /// The instance is passed through to the per-agent client; pipeline policies added via AddPolicy(...) on it execute on the per-agent traffic.
+ /// Endpoint and are owned by this constructor and are overwritten with values derived from ; any caller value is replaced.
+ /// For the project-level conversations client a separate fresh options bag is built that copies only , , , and UserAgentApplicationId; pipeline policies added via AddPolicy(...) do not propagate to the conversations pipeline.
+ ///
+ ///
/// Optional tools to use when interacting with the agent.
/// Provides a way to customize the creation of the underlying .
/// Optional service provider for resolving dependencies required by AI functions.
+ /// or is null.
+ /// does not match the expected agent-endpoint shape.
+ ///
+ /// This is the lightweight constructor for invoking an existing Foundry hosted agent when the
+ /// caller already has the per-agent endpoint URL. It populates
+ /// and from the agent name parsed out of the endpoint
+ /// path; Description, Instructions, Temperature, and TopP are not
+ /// populated. Callers that need those fields hydrated from server-side state should use
+ /// AIProjectClient.AsAIAgent(ProjectsAgentVersion) or
+ /// AIProjectClient.AsAIAgent(ProjectsAgentRecord) instead.
+ ///
public FoundryAgent(
Uri agentEndpoint,
AuthenticationTokenProvider credential,
- AIProjectClientOptions? clientOptions = null,
+ ProjectOpenAIClientOptions? clientOptions = null,
IList? tools = null,
Func? clientFactory = null,
IServiceProvider? services = null)
- : base(CreateInnerAgentFromEndpoint(
- CreateProjectClient(agentEndpoint, credential, clientOptions),
- agentEndpoint, tools, clientFactory, services,
- out var aiProjectClient))
+ : base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
{
- this._aiProjectClient = aiProjectClient;
+ this._projectOpenAIClient = CreateProjectLevelOpenAIClientFromAgentEndpoint(agentEndpoint, credential, clientOptions);
}
///
@@ -105,6 +146,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
+ this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
}
#region Convenience methods
@@ -137,9 +179,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// A linked to the newly created server-side conversation.
public async Task CreateConversationSessionAsync(CancellationToken cancellationToken = default)
{
- var conversationsClient = this._aiProjectClient
- .GetProjectOpenAIClient()
- .GetProjectConversationsClient();
+ var conversationsClient = this._projectOpenAIClient.GetProjectConversationsClient();
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
@@ -161,6 +201,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
return this._aiProjectClient;
}
+ if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient))
+ {
+ return this._projectOpenAIClient;
+ }
+
return base.GetService(serviceType, serviceKey);
}
@@ -238,47 +283,181 @@ public sealed class FoundryAgent : DelegatingAIAgent
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ClientHeadersPolicy.Instance,
- System.ClientModel.Primitives.PipelinePosition.PerCall);
+ PipelinePosition.PerCall);
}
return new ClientHeadersAgent(innerAgent);
}
- private static AIAgent CreateInnerAgentFromEndpoint(
- AIProjectClient aiProjectClient,
+ ///
+ /// Builds the inner for the agent-endpoint constructor by
+ /// constructing a per-agent via the
+ /// ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions)
+ /// constructor with set. This routes the
+ /// outbound URL through the per-agent endpoint shape that the Foundry service expects for
+ /// hosted agents and lets the SDK auto-append the api-version query string.
+ /// Caller-supplied are passed through to the per-agent
+ /// client with Endpoint and
+ /// overridden by values derived from
+ /// ; any policies the caller added via AddPolicy
+ /// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last.
+ ///
+ private static AIAgent CreateInnerAgentFromAgentEndpoint(
Uri agentEndpoint,
+ AuthenticationTokenProvider credential,
+ ProjectOpenAIClientOptions? clientOptions,
IList? tools,
Func? clientFactory,
- IServiceProvider? services,
- out AIProjectClient outClient)
+ IServiceProvider? services)
{
- outClient = aiProjectClient;
+ Throw.IfNull(agentEndpoint);
+ Throw.IfNull(credential);
- AgentReference agentReference = agentEndpoint.Segments[^1].TrimEnd('/');
+ var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
- ChatClientAgentOptions agentOptions = new()
- {
- Name = agentReference.Name,
- ChatOptions = new() { Tools = tools },
- };
+ var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
+ perAgentOptions.Endpoint = agentEndpoint;
+ perAgentOptions.AgentName = agentName;
+ perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
- IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
+ var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope);
+ var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions);
+ IChatClient chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient();
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
+ ChatClientAgentOptions agentOptions = new()
+ {
+ Id = agentName,
+ Name = agentName,
+ ChatOptions = new() { Tools = tools },
+ };
+
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
}
+ ///
+ /// Builds the project-scoped for the agent-endpoint
+ /// constructor by deriving the project root from the supplied agent endpoint and constructing
+ /// a fresh client without so the SDK
+ /// appends the standard /openai/v1 suffix expected for project-level surfaces such as
+ /// conversations.
+ ///
+ ///
+ /// Only the four observable primitive properties (,
+ /// , ,
+ /// and UserAgentApplicationId) are copied from the caller's options bag. Pipeline
+ /// policies added via AddPolicy on the caller bag do not propagate because
+ /// does not publicly enumerate its policies. The MEAI
+ /// user-agent policy is appended last.
+ ///
+ private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint(
+ Uri agentEndpoint,
+ AuthenticationTokenProvider credential,
+ ProjectOpenAIClientOptions? clientOptions)
+ {
+ var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
+
+ var projectOptions = new ProjectOpenAIClientOptions();
+ if (clientOptions is not null)
+ {
+ if (clientOptions.RetryPolicy is not null)
+ {
+ projectOptions.RetryPolicy = clientOptions.RetryPolicy;
+ }
+
+ if (clientOptions.NetworkTimeout is not null)
+ {
+ projectOptions.NetworkTimeout = clientOptions.NetworkTimeout;
+ }
+
+ if (clientOptions.Transport is not null)
+ {
+ projectOptions.Transport = clientOptions.Transport;
+ }
+
+ if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId))
+ {
+ projectOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId;
+ }
+ }
+
+ projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
+
+ return new ProjectOpenAIClient(projectRoot, credential, projectOptions);
+ }
+
+ ///
+ /// Parses an agent endpoint URI of shape
+ /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai
+ /// and returns the agent name and the derived project-root URI.
+ ///
+ ///
+ /// Single source of truth for both agent-name extraction and project-root derivation.
+ /// Tolerates trailing slash, casing variants on /agents/ and the suffix segment, and
+ /// strips query string and fragment. Throws for inputs that
+ /// do not match the expected shape.
+ ///
+ ///
+ /// The endpoint is missing the /agents/ segment, has an empty agent name, or has a
+ /// suffix other than /endpoint/protocols/openai.
+ ///
+ internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
+ {
+ Throw.IfNull(agentEndpoint);
+
+ const string AgentsSegment = "/agents/";
+ const string ExpectedSuffix = "/endpoint/protocols/openai";
+
+ var path = agentEndpoint.AbsolutePath.TrimEnd('/');
+ var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase);
+ if (idx < 0)
+ {
+ throw new ArgumentException(
+ $"Expected an agent endpoint of shape 'https:///.../projects//agents//endpoint/protocols/openai' but got '{agentEndpoint}'. " +
+ "If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.",
+ nameof(agentEndpoint));
+ }
+
+ var afterAgents = path.Substring(idx + AgentsSegment.Length);
+ var nextSlash = afterAgents.IndexOf('/');
+ if (nextSlash <= 0)
+ {
+ throw new ArgumentException(
+ $"Agent endpoint '{agentEndpoint}' is missing the '{ExpectedSuffix}' suffix.",
+ nameof(agentEndpoint));
+ }
+
+ var agentName = afterAgents.Substring(0, nextSlash);
+ var suffix = afterAgents.Substring(nextSlash);
+ if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException(
+ $"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.",
+ nameof(agentEndpoint));
+ }
+
+ var rootPath = path.Substring(0, idx);
+ var projectRoot = new UriBuilder(agentEndpoint)
+ {
+ Path = rootPath,
+ Query = string.Empty,
+ Fragment = string.Empty,
+ }.Uri;
+
+ return (agentName, projectRoot);
+ }
+
private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null)
{
Throw.IfNull(endpoint);
Throw.IfNull(credential);
clientOptions ??= new AIProjectClientOptions();
- clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, System.ClientModel.Primitives.PipelinePosition.PerCall);
+ clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
return new AIProjectClient(endpoint, credential, clientOptions);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj
index 71e7df373f..06e22b8c18 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj
@@ -1,7 +1,10 @@
- true
+
true$(NoWarn);OPENAI001
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
index e7340cec19..fad6b4e316 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
@@ -151,6 +151,36 @@ public sealed class ChatClientAgentOptions
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool RequirePerServiceCallChatHistoryPersistence { get; set; }
+ ///
+ /// Gets or sets a value indicating whether to include a
+ /// in the chat client pipeline.
+ ///
+ ///
+ ///
+ /// When set to , a is added to the pipeline
+ /// between the and the inner client. This enables external code
+ /// (such as tool delegates) to inject messages into the function execution loop via the
+ /// class, which can be resolved from the chat client using
+ /// GetService<MessageInjectingChatClient>().
+ ///
+ ///
+ /// This setting can be used independently of ,
+ /// however it is recommended to also enable per-service-call persistence when using message injection
+ /// so that injected messages are persisted to chat history between service calls.
+ ///
+ ///
+ /// When setting the setting to and
+ /// to , ensure that your custom chat client stack
+ /// includes a . You can add one manually via the
+ /// extension method.
+ ///
+ ///
+ ///
+ /// Default is .
+ ///
+ [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+ public bool EnableMessageInjection { get; set; }
+
///
/// Creates a new instance of with the same values as this instance.
///
@@ -168,5 +198,6 @@ public sealed class ChatClientAgentOptions
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
+ EnableMessageInjection = this.EnableMessageInjection,
};
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs
index 5cf87aa950..22027a44a6 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs
@@ -114,4 +114,38 @@ public static class ChatClientBuilderExtensions
{
return builder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
}
+
+ ///
+ /// Adds a to the chat client pipeline.
+ ///
+ ///
+ ///
+ /// This decorator enables external code (such as tool delegates) to inject messages into the function
+ /// execution loop. It should be positioned between the and
+ /// the (or the leaf )
+ /// in the pipeline.
+ ///
+ ///
+ /// The can be retrieved from the chat client via
+ /// GetService<MessageInjectingChatClient> to enqueue messages from tool delegates or other code.
+ ///
+ ///
+ /// This extension method is intended for use with custom chat client stacks when
+ /// is .
+ /// When is (the default),
+ /// the automatically includes this decorator in the pipeline when
+ /// is .
+ ///
+ ///
+ /// This decorator only works within the context of a running and will throw an
+ /// exception if used in any other stack.
+ ///
+ ///
+ /// The to add the decorator to.
+ /// The for chaining.
+ [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+ public static ChatClientBuilder UseMessageInjection(this ChatClientBuilder builder)
+ {
+ return builder.Use(innerClient => new MessageInjectingChatClient(innerClient));
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
index b2c48ec572..5859e98032 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
@@ -63,13 +63,21 @@ public static class ChatClientExtensions
});
}
- // PerServiceCallChatHistoryPersistingChatClient is only injected when RequirePerServiceCallChatHistoryPersistence is enabled.
- // It is registered after FunctionInvokingChatClient so that it sits between FIC and the leaf client.
+ // MessageInjectingChatClient is injected when EnableMessageInjection is enabled.
+ // It is registered after FunctionInvokingChatClient so that it sits between FIC and the inner client.
// ChatClientBuilder.Build applies factories in reverse order, making the first Use() call outermost.
- // By adding our decorator second, the resulting pipeline is:
- // FunctionInvokingChatClient → PerServiceCallChatHistoryPersistingChatClient → leaf IChatClient
- // This allows the decorator to simulate service-stored chat history by loading history before
- // each service call, persisting after each call, and returning a sentinel ConversationId.
+ // MessageInjectingChatClient enables injecting messages during the function loop and looping when needed.
+ if (options?.EnableMessageInjection is true)
+ {
+ chatBuilder.Use(innerClient => new MessageInjectingChatClient(innerClient));
+ }
+
+ // PerServiceCallChatHistoryPersistingChatClient is injected when RequirePerServiceCallChatHistoryPersistence is enabled.
+ // It is registered after MessageInjectingChatClient (if present) so it sits closest to the leaf client.
+ // The resulting pipeline is:
+ // FunctionInvokingChatClient → [MessageInjectingChatClient] → [PerServiceCallChatHistoryPersistingChatClient] → leaf IChatClient
+ // PerServiceCallChatHistoryPersistingChatClient simulates service-stored chat history by loading history
+ // before each service call, persisting after each call, and returning a sentinel ConversationId.
if (options?.RequirePerServiceCallChatHistoryPersistence is true)
{
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/MessageInjectingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/MessageInjectingChatClient.cs
new file mode 100644
index 0000000000..5d43b55f3e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/MessageInjectingChatClient.cs
@@ -0,0 +1,320 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// A delegating chat client that supports injecting messages into the function execution loop.
+///
+///
+///
+/// This decorator enables external code (such as tool delegates) to enqueue messages that will be
+/// sent to the underlying model at the next opportunity. It sits between the
+/// and the (or the leaf )
+/// in a pipeline.
+///
+///
+/// The injected messages queue is stored per-session in the , ensuring
+/// isolation between concurrent sessions.
+///
+///
+/// After each service call, if no actionable is returned but injected
+/// messages are pending, the decorator loops internally and calls the inner client again with the new
+/// messages. When actionable function calls are present, control returns to the parent
+/// loop.
+///
+///
+/// This chat client must be used within the context of a running . It retrieves the
+/// current session from , which is set automatically when an agent's
+/// or
+///
+/// method is called.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class MessageInjectingChatClient : DelegatingChatClient
+{
+ ///
+ /// The key used to store the pending injected messages queue in the session's .
+ ///
+ internal const string PendingMessagesStateKey = "MessageInjectingChatClient.PendingInjectedMessages";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The underlying chat client that will handle the core operations.
+ public MessageInjectingChatClient(IChatClient innerClient)
+ : base(innerClient)
+ {
+ }
+
+ ///
+ public override async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var session = GetRequiredSession();
+ var queue = GetOrCreateQueue(session);
+
+ var newMessages = DrainInjectedMessages(queue, messages as IList ?? messages.ToList());
+
+ // Loop to process injected messages: after each service call, if no actionable function calls
+ // are pending but new messages have been injected into the queue, we call the service again
+ // so the model can process them. The loop exits when the response contains actionable
+ // function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty.
+ while (true)
+ {
+ var response = await base.GetResponseAsync(newMessages, options, cancellationToken).ConfigureAwait(false);
+
+ // If the response contains actionable function calls, the parent FunctionInvokingChatClient
+ // loop will iterate — return immediately so it can process them.
+ if (HasActionableFunctionCalls(response.Messages))
+ {
+ return response;
+ }
+
+ // No actionable function calls. If there are pending injected messages, loop again
+ // to send them to the service. Otherwise, we're done.
+ bool queueEmpty;
+ lock (queue)
+ {
+ queueEmpty = queue.Count == 0;
+ }
+
+ if (queueEmpty)
+ {
+ return response;
+ }
+
+ // Propagate any ConversationId returned by the service so subsequent iterations
+ // continue within the same conversation.
+ UpdateOptionsForNextIteration(ref options, response.ConversationId);
+
+ newMessages = DrainInjectedMessages(queue, Array.Empty());
+ }
+ }
+
+ ///
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var session = GetRequiredSession();
+ var queue = GetOrCreateQueue(session);
+
+ var newMessages = DrainInjectedMessages(queue, messages as IList ?? messages.ToList());
+
+ // Loop to process injected messages: after each service call, if no actionable function calls
+ // are pending but new messages have been injected into the queue, we call the service again
+ // so the model can process them. The loop exits when the response contains actionable
+ // function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty.
+ while (true)
+ {
+ bool hasActionableFunctionCalls = false;
+ string? lastConversationId = null;
+
+ var enumerator = base.GetStreamingResponseAsync(newMessages, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
+ try
+ {
+ while (await enumerator.MoveNextAsync().ConfigureAwait(false))
+ {
+ var update = enumerator.Current;
+
+ // Check each update for actionable function call content as it streams through.
+ if (!hasActionableFunctionCalls && HasActionableFunctionCalls(update))
+ {
+ hasActionableFunctionCalls = true;
+ }
+
+ // Track the latest ConversationId from the stream.
+ if (update.ConversationId is not null)
+ {
+ lastConversationId = update.ConversationId;
+ }
+
+ yield return update;
+ }
+ }
+ finally
+ {
+ await enumerator.DisposeAsync().ConfigureAwait(false);
+ }
+
+ // If the response contains actionable function calls, the parent FunctionInvokingChatClient
+ // loop will iterate — return immediately so it can process them.
+ if (hasActionableFunctionCalls)
+ {
+ yield break;
+ }
+
+ // No actionable function calls. If there are pending injected messages, loop again
+ // to send them to the service. Otherwise, we're done.
+ bool queueEmpty;
+ lock (queue)
+ {
+ queueEmpty = queue.Count == 0;
+ }
+
+ if (queueEmpty)
+ {
+ yield break;
+ }
+
+ // Propagate any ConversationId returned by the service so subsequent iterations
+ // continue within the same conversation.
+ UpdateOptionsForNextIteration(ref options, lastConversationId);
+
+ newMessages = DrainInjectedMessages(queue, Array.Empty());
+ }
+ }
+
+ ///
+ /// Enqueues one or more messages to be used at the next opportunity.
+ ///
+ ///
+ /// This method is thread-safe and can be called concurrently from tool delegates or other code
+ /// while the function execution loop is in progress. The enqueued messages will be picked up
+ /// at the next opportunity.
+ ///
+ /// The agent session to enqueue messages for.
+ /// The messages to enqueue.
+ public void EnqueueMessages(AgentSession session, IEnumerable messages)
+ {
+ Throw.IfNull(session);
+ Throw.IfNull(messages);
+
+ var queue = GetOrCreateQueue(session);
+
+ lock (queue)
+ {
+ foreach (var message in messages)
+ {
+ queue.Add(message);
+ }
+ }
+ }
+
+ ///
+ /// Gets or creates the pending injected messages queue from the session's .
+ ///
+ private static List GetOrCreateQueue(AgentSession session)
+ {
+ if (session.StateBag.TryGetValue>(PendingMessagesStateKey, out var queue))
+ {
+ return queue!;
+ }
+
+ var newQueue = new List();
+ session.StateBag.SetValue(PendingMessagesStateKey, newQueue);
+ return newQueue;
+ }
+
+ ///
+ /// Gets the current from the run context.
+ ///
+ private static AgentSession GetRequiredSession()
+ {
+ var runContext = AIAgent.CurrentRunContext
+ ?? throw new InvalidOperationException(
+ $"{nameof(MessageInjectingChatClient)} can only be used within the context of a running AIAgent. " +
+ "Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
+
+ return runContext.Session
+ ?? throw new InvalidOperationException(
+ $"{nameof(MessageInjectingChatClient)} requires a session. " +
+ "The current run context does not have a session.");
+ }
+
+ ///
+ /// Drains all pending injected messages from the queue and returns a new list combining
+ /// the original messages with the drained messages. The original list is never modified.
+ ///
+ private static IList DrainInjectedMessages(List queue, IList newMessages)
+ {
+ lock (queue)
+ {
+ if (queue.Count == 0)
+ {
+ return newMessages;
+ }
+
+ var combined = new List(newMessages);
+ combined.AddRange(queue);
+ queue.Clear();
+ return combined;
+ }
+ }
+
+ ///
+ /// Determines whether any message in the list contains a
+ /// that is not marked as .
+ ///
+ private static bool HasActionableFunctionCalls(IList responseMessages)
+ {
+ for (int i = 0; i < responseMessages.Count; i++)
+ {
+ var contents = responseMessages[i].Contents;
+ for (int j = 0; j < contents.Count; j++)
+ {
+ if (contents[j] is FunctionCallContent fcc && !fcc.InformationalOnly)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Determines whether a streaming update contains a
+ /// that is not marked as .
+ ///
+ private static bool HasActionableFunctionCalls(ChatResponseUpdate update)
+ {
+ var contents = update.Contents;
+ for (int i = 0; i < contents.Count; i++)
+ {
+ if (contents[i] is FunctionCallContent fcc && !fcc.InformationalOnly)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Propagates the from the service response into
+ /// so that subsequent loop iterations continue within the
+ /// same conversation. Clones before mutating to avoid
+ /// affecting the caller's instance.
+ ///
+ private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId)
+ {
+ if (options is null)
+ {
+ if (conversationId is not null)
+ {
+ options = new() { ConversationId = conversationId };
+ }
+ }
+ else if (options.ConversationId != conversationId)
+ {
+ options = options.Clone();
+ options.ConversationId = conversationId;
+ }
+ }
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
index 3ec4665672..052786890b 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
@@ -32,6 +32,7 @@ AIAgent agent = scenario switch
"toolbox" => CreateToolboxAgent(projectClient, deployment),
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
+ "session-files" => CreateSessionFilesAgent(projectClient, deployment),
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
};
@@ -106,6 +107,30 @@ static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deploymen
name: "custom-storage-agent",
description: "Custom storage test agent (placeholder).");
+// session-files scenario: agent reads files from $HOME inside the per-session sandbox volume.
+// Mirrors the dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files sample.
+static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment) =>
+ client.AsAIAgent(
+ model: deployment,
+ instructions: """
+ You are a friendly assistant that helps users inspect and summarise
+ files stored in the session sandbox at $HOME.
+
+ Always answer file-related questions by calling the available tools
+ (GetHomeDirectory, ListFiles, ReadFile). Do not guess file paths or
+ contents — read the file before answering.
+
+ Quote numbers and figures verbatim from the file rather than
+ paraphrasing them.
+ """,
+ name: "session-files-agent",
+ description: "Reads files from the per-session $HOME volume.",
+ tools: [
+ AIFunctionFactory.Create(GetHomeDirectory),
+ AIFunctionFactory.Create(ListFiles),
+ AIFunctionFactory.Create(ReadFile)
+ ]);
+
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
@@ -120,3 +145,74 @@ static string SendEmail(
[Description("Returns the deployment environment name.")]
static string GetEnvironmentName() => "integration-test";
+
+// session-files tools: resolve paths against $HOME (the per-session sandbox volume).
+[Description("Get the absolute path of the session home directory ($HOME).")]
+static string GetHomeDirectory() => SessionHome();
+
+[Description("List files and directories under the given path inside the session sandbox. Pass an empty string to list $HOME.")]
+static string[] ListFiles(
+ [Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path)
+{
+ try
+ {
+ return Directory.EnumerateFileSystemEntries(ResolveSessionPath(path)).ToArray();
+ }
+ catch (Exception ex)
+ {
+ return [$"Error listing '{path}': {ex.Message}"];
+ }
+}
+
+[Description("Read the full text contents of a file inside the session sandbox.")]
+static string ReadFile(
+ [Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path)
+{
+ try
+ {
+ return File.ReadAllText(ResolveSessionPath(path));
+ }
+ catch (Exception ex)
+ {
+ return $"Error reading '{path}': {ex.Message}";
+ }
+}
+
+static string SessionHome() =>
+ Environment.GetEnvironmentVariable("HOME")
+ ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+
+// Resolve a caller-supplied path against $HOME, rejecting absolute paths and traversal segments
+// so that the model cannot read or list arbitrary container files via the ReadFile/ListFiles
+// tools (defense-in-depth against indirect prompt injection). Mirrors the canonicalize +
+// startsWith($HOME) pattern used by FileSystemAgentFileStore.ResolveSafePath.
+static string ResolveSessionPath(string path)
+{
+ string home = SessionHome();
+ string homeFull = Path.GetFullPath(home);
+ string homePrefix = homeFull.EndsWith(Path.DirectorySeparatorChar)
+ ? homeFull
+ : homeFull + Path.DirectorySeparatorChar;
+
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return homeFull;
+ }
+
+ if (Path.IsPathRooted(path))
+ {
+ throw new ArgumentException($"Absolute paths are not allowed: '{path}'.", nameof(path));
+ }
+
+ string combined = Path.Combine(homeFull, path);
+ string fullPath = Path.GetFullPath(combined);
+
+ if (!fullPath.Equals(homeFull, StringComparison.Ordinal) &&
+ !fullPath.StartsWith(homePrefix, StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ $"Path '{path}' resolves outside the session sandbox.", nameof(path));
+ }
+
+ return fullPath;
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/SessionFilesHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/SessionFilesHostedAgentFixture.cs
new file mode 100644
index 0000000000..1470a23fba
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/SessionFilesHostedAgentFixture.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Foundry.Hosting.IntegrationTests.Fixtures;
+
+///
+/// Provisions a hosted agent that runs the test container in IT_SCENARIO=session-files mode.
+/// The container exposes three local function tools (GetHomeDirectory, ListFiles,
+/// ReadFile) that read from the per-session $HOME sandbox volume — mirroring the
+/// Hosted-Files sample. Tests use the alpha
+/// API to upload a file into the session
+/// sandbox, then invoke the agent (pinned to the same agent_session_id) and assert that the
+/// agent's tools observed the uploaded file.
+///
+public sealed class SessionFilesHostedAgentFixture : HostedAgentFixture
+{
+ protected override string ScenarioName => "session-files";
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj b/dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj
index 18710dc791..a8ad919198 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj
@@ -20,8 +20,16 @@
+
+
+
+
+
+
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
index 764a366289..91f2f6983d 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
@@ -138,6 +138,7 @@ human-only operation; CI only adds and deletes versions under existing agents.
| `ToolboxHostedAgentFixture` | `toolbox` | `it-toolbox` | Server registered toolbox tool callable; client side additions visible (placeholder). |
| `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). |
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
+| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. |
The placeholder scenarios will be wired up in the test container `Program.cs` once the
relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize.
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs
new file mode 100644
index 0000000000..7c1407ac65
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs
@@ -0,0 +1,238 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+#pragma warning disable AAIP001 // AgentSessionFiles is experimental
+#pragma warning disable OPENAI001 // CreateResponseOptions is experimental
+
+using System;
+using System.ClientModel;
+using System.ClientModel.Primitives;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using AgentConformance.IntegrationTests.Support;
+using Azure.AI.Extensions.OpenAI;
+using Azure.AI.Projects;
+using Azure.AI.Projects.Agents;
+using Foundry.Hosting.IntegrationTests.Fixtures;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+using OpenAI.Responses;
+using Shared.IntegrationTests;
+
+namespace Foundry.Hosting.IntegrationTests;
+
+///
+/// End-to-end integration test for the Hosted-Files style scenario: a file uploaded by the client
+/// via the alpha SDK is read by the deployed hosted agent's
+/// container-side ReadFile tool and surfaces in .
+///
+///
+///
+/// Routing both invocations to the same per-session container requires two clients on the same
+/// agent-scoped : a to
+/// pre-create a conversation bound to the agent endpoint, and a
+/// for invocation. The session id resolved by the platform on the first call is captured from the
+/// x-agent-session-id response header and used to target the
+/// upload at the same session's $HOME. The second call
+/// carries the same conversation_id so it lands in the same container and the agent's
+/// ReadFile tool sees the upload.
+///
+///
+[Trait("Category", "FoundryHostedAgents")]
+public sealed class SessionFilesHostedAgentTests(SessionFilesHostedAgentFixture fixture) : IClassFixture
+{
+ private const string FoundryFeaturesHeader = "Foundry-Features";
+ private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview,AgentEndpoints=V1Preview";
+ private const string SessionIdHeader = "x-agent-session-id";
+
+ private const string TestDataFileName = "contoso_q1_2026_report.txt";
+
+ /// Token that appears verbatim in the test data file. Proof the agent read what we uploaded.
+ private const string ExpectedTokenInFile = "1,482.6";
+
+ private readonly SessionFilesHostedAgentFixture _fixture = fixture;
+
+ [Fact]
+ public async Task UploadedFile_IsReadByHostedAgentAsync()
+ {
+ // Arrange
+ string localPath = Path.Combine(AppContext.BaseDirectory, "TestData", TestDataFileName);
+ Assert.True(
+ File.Exists(localPath),
+ $"Test data file not found at '{localPath}'. Confirm the linked Content entry in the csproj.");
+
+ var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
+ var credential = TestAzureCliCredentials.CreateAzureCliCredential();
+
+ // Admin client + AgentSessionFiles for upload/list/delete (alpha SDK).
+ var adminOptions = new AgentAdministrationClientOptions();
+ adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
+ var adminClient = new AgentAdministrationClient(endpoint, credential, adminOptions);
+ var sessionFiles = adminClient.GetAgentSessionFiles();
+
+ // Build the per-agent OpenAI client. The conversation is created on this client so it is
+ // bound to the agent endpoint URL (`/agents/{name}/endpoint/protocols/openai/conversations`).
+ // A header-capture policy reads the `x-agent-session-id` the platform stamps on every reply.
+ var headerCapture = new ResponseHeaderCapturePolicy(SessionIdHeader);
+ var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this._fixture.AgentName };
+ openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
+ openAIOptions.AddPolicy(headerCapture, PipelinePosition.PerCall);
+ var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions);
+ var conversations = openAIClient.GetProjectConversationsClient();
+ var responses = openAIClient.GetProjectResponsesClient();
+
+ // Step 1 — create a conversation bound to the agent endpoint. Subsequent /responses calls
+ // tagged with this conversation_id route to the same per-session container.
+ var conversation = await conversations.CreateProjectConversationAsync();
+ string conversationId = conversation.Value.Id;
+
+ try
+ {
+ // Step 2 — warm-up call. Provisions the per-session container under the conversation and
+ // lets us read back the resolved agent_session_id from the response header.
+ var agent = responses.AsIChatClient().AsAIAgent(name: this._fixture.AgentName);
+ var convOptions = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
+
+ var warmup = await agent.RunAsync(
+ "Reply with the single word 'ready' and nothing else.",
+ options: convOptions);
+ Assert.False(string.IsNullOrWhiteSpace(warmup.Text));
+
+ string agentSessionId = headerCapture.LastValue
+ ?? throw new InvalidOperationException(
+ $"Expected '{SessionIdHeader}' response header on warm-up but got none.");
+
+ try
+ {
+ // Step 3 — upload the file via the alpha AgentSessionFiles SDK to that exact session's $HOME.
+ SessionFileWriteResponse writeResponse = await sessionFiles.UploadSessionFileAsync(
+ agentName: this._fixture.AgentName,
+ sessionId: agentSessionId,
+ sessionStoragePath: TestDataFileName,
+ localPath: localPath);
+
+ long expectedBytes = new FileInfo(localPath).Length;
+ Assert.Equal(expectedBytes, writeResponse.BytesWritten);
+
+ SessionDirectoryListResponse listing = await sessionFiles.GetSessionFilesAsync(
+ agentName: this._fixture.AgentName,
+ sessionId: agentSessionId,
+ sessionStoragePath: ".");
+ Assert.Contains(
+ listing.Entries,
+ e => e.Name == TestDataFileName && !e.IsDirectory && e.Size == expectedBytes);
+
+ // Step 4 — invoke the agent again on the SAME conversation. The platform routes back to
+ // the same agent_session_id container, so the agent's ReadFile tool sees the upload.
+ // The platform mutates session/conversation revision when AgentSessionFiles uploads land,
+ // so an immediate /responses follow-up races and 400's with "modified concurrently. Please
+ // retry." — the response message literally tells us to retry. Bounded retry handles it.
+ var readOptions = new CreateResponseOptions { AgentConversationId = conversationId };
+ readOptions.InputItems.Add(ResponseItem.CreateUserMessageItem(
+ $"Read {TestDataFileName} from $HOME and quote the headline total revenue figure verbatim, no commentary."));
+
+ ClientResult rawResponse = null!;
+ const int MaxAttempts = 5;
+ for (int attempt = 1; attempt <= MaxAttempts; attempt++)
+ {
+ try
+ {
+ rawResponse = await responses.CreateResponseAsync(readOptions);
+ break;
+ }
+ catch (ClientResultException ex) when (
+ ex.Status == 400 &&
+ ex.Message.Contains("modified concurrently", StringComparison.OrdinalIgnoreCase) &&
+ attempt < MaxAttempts)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(2 * attempt));
+ }
+ }
+
+ string responseText = rawResponse.Value.GetOutputText() ?? string.Empty;
+
+ Assert.Equal(agentSessionId, headerCapture.LastValue);
+
+ // Assert: the response contains the deterministic token from the file.
+ Assert.False(string.IsNullOrWhiteSpace(responseText));
+ Assert.Contains(ExpectedTokenInFile, responseText);
+ }
+ finally
+ {
+ // Best-effort cleanup of the uploaded file. The session itself is left for TTL expiry —
+ // the platform owns its lifecycle (no isolation key in our hands).
+ try
+ {
+ await sessionFiles.DeleteSessionFileAsync(
+ agentName: this._fixture.AgentName,
+ sessionId: agentSessionId,
+ path: TestDataFileName);
+ }
+ catch
+ {
+ // Ignore.
+ }
+ }
+ }
+ finally
+ {
+ await this._fixture.DeleteConversationAsync(conversationId);
+ }
+ }
+
+ ///
+ /// Captures a response header value on every pipeline call. Latest value is read after the
+ /// response completes. Used to grab the platform's x-agent-session-id stamp.
+ ///
+ private sealed class ResponseHeaderCapturePolicy(string headerName) : PipelinePolicy
+ {
+ private readonly string _headerName = headerName;
+ private string? _lastValue;
+
+ public string? LastValue => Volatile.Read(ref this._lastValue);
+
+ public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ ProcessNext(message, pipeline, currentIndex);
+ this.Capture(message);
+ }
+
+ public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
+ this.Capture(message);
+ }
+
+ private void Capture(PipelineMessage message)
+ {
+ if (message.Response is not null &&
+ message.Response.Headers.TryGetValue(this._headerName, out var value) &&
+ !string.IsNullOrEmpty(value))
+ {
+ Volatile.Write(ref this._lastValue, value);
+ }
+ }
+ }
+
+ private sealed class FoundryFeaturesPolicy(string features) : PipelinePolicy
+ {
+ public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ this.SetHeader(message);
+ ProcessNext(message, pipeline, currentIndex);
+ }
+
+ public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ this.SetHeader(message);
+ await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
+ }
+
+ private void SetHeader(PipelineMessage message)
+ {
+ message.Request.Headers.Remove(FoundryFeaturesHeader);
+ message.Request.Headers.Add(FoundryFeaturesHeader, features);
+ }
+ }
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1
index c683a2a4e3..1896a8407c 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1
@@ -38,7 +38,8 @@ $Scenarios = @(
'tool-calling-approval',
'toolbox',
'mcp-toolbox',
- 'custom-storage'
+ 'custom-storage',
+ 'session-files'
)
# Resolve project ARM scope from the endpoint.
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1
index 857ea3be0a..2d938bb013 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1
@@ -41,14 +41,7 @@ param(
[string] $Repository = "foundry-hosting-it",
- [string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer",
-
- # Explicit opt-in for the no-rebuild fast path. CI sets this after running the
- # "Build Foundry hosted IT (and its deps)" step, which guarantees the prebuilt
- # library DLLs match current source. Off by default so local invocations always
- # let publish rebuild ProjectReferences and never produce an image whose tag is
- # computed from current source while the contents come from a stale build.
- [switch] $UsePrebuiltProjectReferences
+ [string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer"
)
$ErrorActionPreference = "Stop"
@@ -107,60 +100,35 @@ if (Test-Path $out) {
Remove-Item -Recurse -Force $out
}
-# Conditionally tell publish to skip rebuilding ProjectReferences and consume the
-# prebuilt library DLLs in place. This avoids two failure modes that arise when
-# the CI workflow runs a `dotnet build` of the same library projects immediately
-# before this script:
-# 1) MSB3026 "file is being used by another process" when publish's MSBuild
-# tries to overwrite src//bin/Release/net10.0/.dll while the
-# previous build's shared-compilation server still holds a file handle.
-# 2) Publish needlessly rebuilding identical managed (RID-agnostic) library
-# DLLs that prebuild already produced.
-# Gated on -UsePrebuiltProjectReferences (a strict opt-in) instead of marker
-# detection, because a developer machine may have a stale Release build of the
-# libraries from days ago; using those would silently produce an image whose
-# content is older than the source the tag is computed from.
-$publishExtraArgs = @()
-if ($UsePrebuiltProjectReferences) {
- Write-Host "-UsePrebuiltProjectReferences: skipping ProjectReference rebuild." -ForegroundColor DarkGray
- $publishExtraArgs += "-p:BuildProjectReferences=false"
-} else {
- # Preflight: in default (rebuild) mode, publish propagates RuntimeIdentifier=linux-musl-x64
- # to library ProjectReferences and writes their intermediates to a RID-suffixed obj path
- # (e.g. obj/Release/net10.0/linux-musl-x64/). DefaultItemExcludes follows the new
- # IntermediateOutputPath, so any *.AssemblyInfo.cs left in obj/Release/net10.0/ from a
- # prior `dotnet build` is no longer excluded and gets picked up by the **/*.cs Compile
- # glob, producing CS0579 "duplicate attribute" errors. Detect that state up front and
- # tell the user exactly how to recover.
- $staleObjProbes = @(
- "dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/obj/Release/net10.0",
- "dotnet/src/Microsoft.Agents.AI.Foundry/obj/Release/net10.0",
- "dotnet/src/Microsoft.Agents.AI/obj/Release/net10.0",
- "dotnet/src/Microsoft.Agents.AI.Abstractions/obj/Release/net10.0"
- )
- $stale = @($staleObjProbes | Where-Object { Test-Path (Join-Path $_ "*.AssemblyInfo.cs") })
- if ($stale.Count -gt 0) {
- $msg = @(
- "Detected prior Release/net10.0 build outputs in:"
- ($stale | ForEach-Object { " - $_" })
- ""
- "Publish would propagate -r linux-musl-x64 to those ProjectReferences and the"
- "leftover obj/Release/net10.0/*.AssemblyInfo.cs files would cause CS0579 duplicate"
- "attribute errors. Pick one:"
- " (a) Pass -UsePrebuiltProjectReferences (skips ProjectReference rebuild and"
- " uses the existing src//bin/Release/net10.0/*.dll outputs in place)."
- " Only safe when you know those DLLs match current source - this is the path"
- " CI uses immediately after its 'Build Foundry hosted IT (and its deps)' step."
- " (b) Remove the stale obj/Release trees, e.g.:"
- " Remove-Item -Recurse -Force dotnet/src/Microsoft.Agents.AI*/obj/Release"
- " and re-run."
- ) -join "`n"
- throw $msg
- }
- Write-Host "Letting publish build ProjectReferences (pass -UsePrebuiltProjectReferences in CI to skip)." -ForegroundColor DarkGray
+# Always tell publish to skip ProjectReference rebuilds via --no-dependencies. Publish
+# resolves TestContainer's framework lib references (Foundry, Foundry.Hosting and their
+# transitive deps) by reading the prebuilt DLLs at src//bin/Release/net10.0/*.dll.
+# This:
+# 1) Structurally avoids the MSB3026 "file is being used by another process" race that
+# occurs when publish overwrites the same DLL paths a prior `dotnet build` produced
+# while VBCSCompiler from that build still holds file handles.
+# 2) Avoids needlessly rebuilding identical managed (RID-agnostic) library DLLs.
+# Callers MUST run `dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release`
+# (or equivalent) first so those prebuilt DLLs exist. The CI workflow does this in the
+# preceding "Build Foundry hosted IT (and its deps)" step.
+$prebuildProbes = @(
+ "dotnet/src/Microsoft.Agents.AI.Foundry/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.dll",
+ "dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.Hosting.dll"
+)
+$missingPrebuilds = @($prebuildProbes | Where-Object { -not (Test-Path $_) })
+if ($missingPrebuilds.Count -gt 0) {
+ $msg = @(
+ "Required prebuilt outputs not found:"
+ ($missingPrebuilds | ForEach-Object { " - $_" })
+ ""
+ "Publish runs with --no-dependencies and consumes prebuilt DLLs in place. Build the"
+ "test project first so its ProjectReference closure populates src//bin/Release/net10.0/:"
+ " dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release"
+ ) -join "`n"
+ throw $msg
}
-dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false -o $out @publishExtraArgs --tl:off | Out-Host
+dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false --no-dependencies -o $out --tl:off | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE."
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj
index fa28a32494..4a139df1b6 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs
index 45d09689ff..01184031e8 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs
@@ -7,6 +7,7 @@ using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
+using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Microsoft.Extensions.AI;
@@ -184,7 +185,7 @@ public class FoundryAgentTests
// Act: this AsAIAgent path constructs FoundryAgent via its internal
// (AIProjectClient, ChatClientAgent) constructor, which previously bypassed pre-wiring.
- var agent = projectClient.AsAIAgent(new Azure.AI.Extensions.OpenAI.AgentReference("agent-name"));
+ var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
// Assert
Assert.NotNull(agent.GetService());
@@ -398,4 +399,379 @@ public class FoundryAgentTests
}
#endregion
+
+ #region Agent-endpoint constructor tests
+
+ private const string TestAgentEndpoint = "https://test.services.ai.azure.com/api/projects/test-project/agents/it-happy-path/endpoint/protocols/openai";
+ private static readonly Uri s_testAgentEndpoint = new(TestAgentEndpoint);
+
+ [Fact]
+ public void AgentEndpointConstructor_NullEndpoint_ThrowsArgumentNullException()
+ {
+ ArgumentNullException ex = Assert.Throws(() =>
+ new FoundryAgent(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider()));
+ Assert.Equal("agentEndpoint", ex.ParamName);
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_NullCredential_ThrowsArgumentNullException()
+ {
+ ArgumentNullException ex = Assert.Throws(() =>
+ new FoundryAgent(agentEndpoint: s_testAgentEndpoint, credential: null!));
+ Assert.Equal("credential", ex.ParamName);
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_PopulatesNameAndIdFromEndpointSlug()
+ {
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
+
+ Assert.Equal("it-happy-path", agent.Name);
+ Assert.Equal("it-happy-path", agent.Id);
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
+ {
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
+
+ Assert.NotNull(agent.GetService());
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull()
+ {
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
+
+ Assert.Null(agent.GetService());
+ }
+
+ [Fact]
+ public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
+ {
+ FoundryAgent agent = new(
+ s_testEndpoint,
+ new FakeAuthenticationTokenProvider(),
+ model: "gpt-4o-mini",
+ instructions: "Test");
+
+ Assert.NotNull(agent.GetService());
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_AppliesClientFactoryOnce()
+ {
+ int count = 0;
+ FoundryAgent agent = new(
+ s_testAgentEndpoint,
+ new FakeAuthenticationTokenProvider(),
+ clientFactory: c => { count++; return c; });
+
+ Assert.Equal(1, count);
+ Assert.NotNull(agent);
+ }
+
+ [Fact]
+ public async Task AgentEndpointConstructor_RunAsync_RoutesThroughPerAgentResponsesUrlAsync()
+ {
+ Uri? capturedUri = null;
+ using HttpHandlerAssert handler = new(req =>
+ {
+ capturedUri = req.RequestUri;
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
+ };
+ });
+#pragma warning disable CA5399
+ using HttpClient http = new(handler);
+#pragma warning restore CA5399
+ ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
+
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
+ await agent.RunAsync("Hello");
+
+ Assert.NotNull(capturedUri);
+ string path = capturedUri!.AbsolutePath;
+ Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", path, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("/openai/v1/responses", path, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task AgentEndpointConstructor_RunStreamingAsync_RoutesThroughPerAgentResponsesUrlAsync()
+ {
+ Uri? capturedUri = null;
+ bool sawStreamTrue = false;
+ using HttpHandlerAssert handler = new(async req =>
+ {
+ capturedUri = req.RequestUri;
+ if (req.Content is not null)
+ {
+ string body = await req.Content.ReadAsStringAsync().ConfigureAwait(false);
+ if (body.Contains("\"stream\":true", StringComparison.Ordinal))
+ {
+ sawStreamTrue = true;
+ }
+ }
+
+ // Minimal SSE response; xUnit assertion only cares about the URL/body shape.
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("data: [DONE]\n\n", Encoding.UTF8, "text/event-stream"),
+ };
+ });
+#pragma warning disable CA5399
+ using HttpClient http = new(handler);
+#pragma warning restore CA5399
+ ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
+
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
+ try
+ {
+ await foreach (var _ in agent.RunStreamingAsync("Hello"))
+ {
+ // drain
+ }
+ }
+ catch
+ {
+ // SSE parse errors are acceptable; we only assert the request shape.
+ }
+
+ Assert.NotNull(capturedUri);
+ Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", capturedUri!.AbsolutePath, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase);
+ Assert.True(sawStreamTrue, "Expected request body to include \"stream\":true.");
+ }
+
+ [Fact]
+ public async Task AgentEndpointConstructor_CreateConversationSessionAsync_RoutesThroughProjectLevelUrlAsync()
+ {
+ Uri? capturedUri = null;
+ using HttpHandlerAssert handler = new(req =>
+ {
+ capturedUri = req.RequestUri;
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"id\":\"conv_123\"}", Encoding.UTF8, "application/json"),
+ };
+ });
+#pragma warning disable CA5399
+ using HttpClient http = new(handler);
+#pragma warning restore CA5399
+ ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
+
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
+ try
+ {
+ _ = await agent.CreateConversationSessionAsync();
+ }
+ catch
+ {
+ // Underlying SDK may attempt extra parsing on the minimal response. We only assert URL routing.
+ }
+
+ Assert.NotNull(capturedUri);
+ string path = capturedUri!.AbsolutePath;
+ Assert.Contains("/api/projects/test-project/openai/v1/conversations", path, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("/agents/", path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task AgentEndpointConstructor_StampsMeaiUserAgentHeaderAsync()
+ {
+ bool meaiSeen = false;
+ using HttpHandlerAssert handler = new(req =>
+ {
+ if (req.Headers.TryGetValues("User-Agent", out var values))
+ {
+ foreach (string v in values)
+ {
+ if (v.IndexOf("MEAI/", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ meaiSeen = true;
+ }
+ }
+ }
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
+ };
+ });
+#pragma warning disable CA5399
+ using HttpClient http = new(handler);
+#pragma warning restore CA5399
+ ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
+
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
+ await agent.RunAsync("Hello");
+
+ Assert.True(meaiSeen, "Expected MEAI/x.y.z to appear in the User-Agent header on the agent-endpoint pipeline.");
+ }
+
+ [Fact]
+ public async Task AgentEndpointConstructor_PassesThroughCallerPolicyOnPerAgentPipelineAsync()
+ {
+ // Direct switch to ProjectOpenAIClientOptions means caller-supplied pipeline policies
+ // (added via AddPolicy) actually flow through to the per-agent traffic. Assert that a
+ // tag-stamping policy executes on each outbound per-agent request.
+ bool tagSeen = false;
+ using HttpHandlerAssert handler = new(req =>
+ {
+ if (req.Headers.TryGetValues("X-Test-Tag", out var values))
+ {
+ foreach (string v in values)
+ {
+ if (v == "tag-1")
+ {
+ tagSeen = true;
+ }
+ }
+ }
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
+ };
+ });
+#pragma warning disable CA5399
+ using HttpClient http = new(handler);
+#pragma warning restore CA5399
+ ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
+ opts.AddPolicy(new HeaderStampPolicy("X-Test-Tag", "tag-1"), PipelinePosition.PerCall);
+
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
+ await agent.RunAsync("Hello");
+
+ Assert.True(tagSeen, "Expected caller-supplied per-call policy to execute on the per-agent pipeline.");
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_OverridesCallerEndpointAndAgentName()
+ {
+ // The caller may set Endpoint/AgentName on the options bag; we must override both with
+ // values derived from agentEndpoint so the URL routing is correct regardless.
+ ProjectOpenAIClientOptions opts = new()
+ {
+ Endpoint = new Uri("https://wrong.example.com/openai/v1"),
+ AgentName = "wrong-agent",
+ };
+
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
+
+ Assert.Equal("it-happy-path", agent.Name);
+ Assert.Equal(s_testAgentEndpoint, opts.Endpoint);
+ Assert.Equal("it-happy-path", opts.AgentName);
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient()
+ {
+ // The MEAI policy adds its own User-Agent header so we cannot reliably observe the OpenAI SDK's
+ // application-id stamp in the outbound request. Verify the value is propagated onto the
+ // project-level client's options via the public ProjectOpenAIClient surface.
+ ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" };
+
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
+
+ ProjectOpenAIClient? projectClient = agent.GetService();
+ Assert.NotNull(projectClient);
+ // Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim.
+ Assert.Equal("my-app-id", opts.UserAgentApplicationId);
+ }
+
+ #endregion
+
+ #region ParseAgentEndpoint tests
+
+ [Fact]
+ public void ParseAgentEndpoint_StandardShape_Parses()
+ {
+ var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai"));
+ Assert.Equal("a1", name);
+ Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/'));
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_TrailingSlash_Parses()
+ {
+ var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai/"));
+ Assert.Equal("a1", name);
+ Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/'));
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_UppercaseAgentsSegment_Parses()
+ {
+ var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/Agents/a1/endpoint/protocols/openai"));
+ Assert.Equal("a1", name);
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_SpecialCharsInName_Parses()
+ {
+ var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/it-happy_path-1/endpoint/protocols/openai"));
+ Assert.Equal("it-happy_path-1", name);
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_QueryAndFragmentStripped()
+ {
+ var (_, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a/endpoint/protocols/openai?x=1#frag"));
+ Assert.Equal(string.Empty, root.Query);
+ Assert.Equal(string.Empty, root.Fragment);
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_SovereignCloudHostNoApiPrefix_Parses()
+ {
+ var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.cognitive.microsoft.us/projects/p/agents/a1/endpoint/protocols/openai"));
+ Assert.Equal("a1", name);
+ Assert.Equal("https://h.cognitive.microsoft.us/projects/p", root.AbsoluteUri.TrimEnd('/'));
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_MissingAgentsSegment_Throws()
+ {
+ ArgumentException ex = Assert.Throws(() =>
+ FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/openai/v1")));
+ Assert.Equal("agentEndpoint", ex.ParamName);
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_WrongSuffix_Throws()
+ {
+ ArgumentException ex = Assert.Throws(() =>
+ FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a1/openai/v1")));
+ Assert.Equal("agentEndpoint", ex.ParamName);
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_EmptyAgentName_Throws()
+ {
+ ArgumentException ex = Assert.Throws(() =>
+ FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents//endpoint/protocols/openai")));
+ Assert.Equal("agentEndpoint", ex.ParamName);
+ }
+
+ #endregion
+
+ private sealed class HeaderStampPolicy : PipelinePolicy
+ {
+ private readonly string _name;
+ private readonly string _value;
+ public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; }
+
+ public override void Process(PipelineMessage message, System.Collections.Generic.IReadOnlyList pipeline, int currentIndex)
+ {
+ message.Request.Headers.Set(this._name, this._value);
+ ProcessNext(message, pipeline, currentIndex);
+ }
+
+ public override ValueTask ProcessAsync(PipelineMessage message, System.Collections.Generic.IReadOnlyList pipeline, int currentIndex)
+ {
+ message.Request.Headers.Set(this._name, this._value);
+ return ProcessNextAsync(message, pipeline, currentIndex);
+ }
+ }
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj
index 3b7176711a..b17efa64f9 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj
@@ -1,4 +1,4 @@
-
+false
@@ -7,7 +7,7 @@
-
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/MessageInjectingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/MessageInjectingChatClientTests.cs
new file mode 100644
index 0000000000..93f6b02b03
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/MessageInjectingChatClientTests.cs
@@ -0,0 +1,493 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+using Moq.Protected;
+
+namespace Microsoft.Agents.AI.UnitTests;
+
+///
+/// Unit tests for .
+///
+public class MessageInjectingChatClientTests
+{
+ ///
+ /// Verifies that is resolvable via GetService when the decorator is active.
+ ///
+ [Fact]
+ public void GetService_ReturnsMessageInjectingChatClient_WhenDecoratorActive()
+ {
+ // Arrange
+ Mock mockService = new();
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ EnableMessageInjection = true,
+ });
+
+ // Act
+ var injector = agent.ChatClient.GetService();
+
+ // Assert
+ Assert.NotNull(injector);
+ }
+
+ ///
+ /// Verifies that is null when the decorator is not active.
+ ///
+ [Fact]
+ public void GetService_ReturnsNull_WhenDecoratorNotActive()
+ {
+ // Arrange
+ Mock mockService = new();
+ ChatClientAgent agent = new(mockService.Object, options: new());
+
+ // Act
+ var injector = agent.ChatClient.GetService();
+
+ // Assert
+ Assert.Null(injector);
+ }
+
+ ///
+ /// Verifies that messages enqueued on the session before RunAsync are included in the service call messages.
+ ///
+ [Fact]
+ public async Task RunAsync_IncludesInjectedMessages_WhenEnqueuedBeforeCallAsync()
+ {
+ // Arrange
+ List capturedMessages = [];
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback((IEnumerable msgs, ChatOptions? _, CancellationToken _) =>
+ capturedMessages.AddRange(msgs))
+ .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+
+ Mock mockChatHistoryProvider = new(null, null, null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ RequirePerServiceCallChatHistoryPersistence = true,
+ EnableMessageInjection = true,
+ });
+
+ // Create session and enqueue a message directly onto the session's StateBag queue before calling RunAsync
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ var queue = new List();
+ queue.Add(new ChatMessage(ChatRole.User, "injected message"));
+ session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue);
+
+ // Act
+ await agent.RunAsync([new(ChatRole.User, "original")], session);
+
+ // Assert — the service should have received both the original and injected messages
+ Assert.Contains(capturedMessages, m => m.Text == "original");
+ Assert.Contains(capturedMessages, m => m.Text == "injected message");
+ }
+
+ ///
+ /// Verifies that the queue is drained after a call (messages are not re-delivered on subsequent calls).
+ ///
+ [Fact]
+ public async Task RunAsync_DrainsQueue_MessagesNotRedeliveredAsync()
+ {
+ // Arrange
+ List capturedMessages = [];
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback((IEnumerable msgs, ChatOptions? _, CancellationToken _) =>
+ capturedMessages.AddRange(msgs))
+ .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+
+ Mock mockChatHistoryProvider = new(null, null, null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ RequirePerServiceCallChatHistoryPersistence = true,
+ EnableMessageInjection = true,
+ });
+
+ // Create session and enqueue a message directly onto the session's StateBag queue
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ var queue = new List();
+ queue.Add(new ChatMessage(ChatRole.User, "injected once"));
+ session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue);
+
+ // Act
+ await agent.RunAsync([new(ChatRole.User, "first call")], session);
+
+ // Assert — the injected message was included in the service call
+ Assert.Contains(capturedMessages, m => m.Text == "injected once");
+
+ // Assert — the session's queue is now empty (drained)
+ Assert.Empty(queue);
+ }
+
+ ///
+ /// Verifies that the internal loop fires when no actionable FunctionCallContent is returned
+ /// but there are pending injected messages in the queue.
+ ///
+ [Fact]
+ public async Task RunAsync_LoopsInternally_WhenNoActionableFCCButPendingMessagesAsync()
+ {
+ // Arrange
+ int serviceCallCount = 0;
+ Mock mockService = new();
+ MessageInjectingChatClient? injectorRef = null;
+ ChatClientAgentSession? sessionRef = null;
+
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) =>
+ {
+ serviceCallCount++;
+ if (serviceCallCount == 1)
+ {
+ // First call — simulate that something enqueues a message (e.g., a provider or background task)
+ injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected during first call")]);
+ }
+
+ // Return a plain text response (no FunctionCallContent) to trigger the internal loop
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, $"response {serviceCallCount}")]));
+ });
+
+ Mock mockChatHistoryProvider = new(null, null, null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ RequirePerServiceCallChatHistoryPersistence = true,
+ EnableMessageInjection = true,
+ });
+
+ injectorRef = agent.ChatClient.GetService()!;
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ sessionRef = session;
+ await agent.RunAsync([new(ChatRole.User, "original")], session);
+
+ // Assert — should have made 2 service calls (internal loop triggered by the injected message)
+ Assert.Equal(2, serviceCallCount);
+ }
+
+ ///
+ /// Verifies that the internal loop does NOT fire when the response contains actionable
+ /// FunctionCallContent, even if there are pending injected messages.
+ ///
+ [Fact]
+ public async Task RunAsync_DoesNotLoopInternally_WhenActionableFCCPresentAsync()
+ {
+ // Arrange
+ int serviceCallCount = 0;
+ Mock mockService = new();
+ MessageInjectingChatClient? injectorRef = null;
+ ChatClientAgentSession? sessionRef = null;
+
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) =>
+ {
+ serviceCallCount++;
+ if (serviceCallCount == 1)
+ {
+ // Enqueue a message during the first call
+ injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]);
+ // Return a response with an actionable FunctionCallContent
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant,
+ [new FunctionCallContent("call1", "myTool", new Dictionary())])]));
+ }
+
+ // Subsequent calls return plain text (the FCC loop will call back after tool execution)
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")]));
+ });
+
+ Mock mockChatHistoryProvider = new(null, null, null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatOptions = new() { Tools = [tool] },
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ RequirePerServiceCallChatHistoryPersistence = true,
+ EnableMessageInjection = true,
+ }, services: new ServiceCollection().BuildServiceProvider());
+
+ injectorRef = agent.ChatClient.GetService()!;
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ sessionRef = session;
+ await agent.RunAsync([new(ChatRole.User, "original")], session);
+
+ // Assert — The first service call returned actionable FCC, so no internal injected-message loop
+ // occurred there. The FCC loop invokes the tool and calls the service again (second call).
+ // The injected message should be picked up by the second service call (drained at start of
+ // GetResponseAsync), but no extra internal loop should fire. Exactly 2 service calls expected.
+ Assert.Equal(2, serviceCallCount);
+ }
+
+ ///
+ /// Verifies that the internal loop fires when the response contains only InformationalOnly
+ /// FunctionCallContent (which are not actionable) and there are pending injected messages.
+ ///
+ [Fact]
+ public async Task RunAsync_LoopsInternally_WhenOnlyInformationalOnlyFCCAndPendingMessagesAsync()
+ {
+ // Arrange
+ int serviceCallCount = 0;
+ Mock mockService = new();
+ MessageInjectingChatClient? injectorRef = null;
+ ChatClientAgentSession? sessionRef = null;
+
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) =>
+ {
+ serviceCallCount++;
+ if (serviceCallCount == 1)
+ {
+ // Enqueue a message during the first call
+ injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]);
+ // Return a response with InformationalOnly FCC (not actionable)
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant,
+ [new FunctionCallContent("call1", "myTool", new Dictionary()) { InformationalOnly = true }])]));
+ }
+
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")]));
+ });
+
+ Mock mockChatHistoryProvider = new(null, null, null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ RequirePerServiceCallChatHistoryPersistence = true,
+ EnableMessageInjection = true,
+ });
+
+ injectorRef = agent.ChatClient.GetService()!;
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ sessionRef = session;
+ await agent.RunAsync([new(ChatRole.User, "original")], session);
+
+ // Assert — InformationalOnly FCC is NOT actionable, so internal loop should trigger
+ Assert.Equal(2, serviceCallCount);
+ }
+
+ ///
+ /// Verifies that when the inner client returns a ConversationId on the first call, the
+ /// MessageInjectingChatClient propagates it to options on subsequent loop iterations.
+ ///
+ [Fact]
+ public async Task RunAsync_PropagatesConversationId_AcrossInternalLoopIterationsAsync()
+ {
+ // Arrange
+ int serviceCallCount = 0;
+ List capturedConversationIds = [];
+ MessageInjectingChatClient? injectorRef = null;
+ ChatClientAgentSession? sessionRef = null;
+
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns((IEnumerable _, ChatOptions? opts, CancellationToken _) =>
+ {
+ serviceCallCount++;
+ capturedConversationIds.Add(opts?.ConversationId);
+
+ if (serviceCallCount == 1)
+ {
+ // First call: inject a message and return a ConversationId
+ injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]);
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "first response")])
+ {
+ ConversationId = "conv-123",
+ });
+ }
+
+ // Second call (from loop): should have the propagated ConversationId
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "second response")]));
+ });
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ EnableMessageInjection = true,
+ }, services: new ServiceCollection().BuildServiceProvider());
+
+ injectorRef = agent.ChatClient.GetService()!;
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ sessionRef = session;
+ await agent.RunAsync([new(ChatRole.User, "hello")], session);
+
+ // Assert — The second call should have received the ConversationId propagated from the first response
+ Assert.Equal(2, serviceCallCount);
+ Assert.Null(capturedConversationIds[0]); // First call: no ConversationId yet
+ Assert.Equal("conv-123", capturedConversationIds[1]); // Second call: propagated from first response
+ }
+
+ ///
+ /// Verifies that a session with pending injected messages can be serialized and deserialized,
+ /// and that the deserialized session correctly delivers the injected messages on the next run.
+ ///
+ [Fact]
+ public async Task RunAsync_DeliversInjectedMessages_AfterSessionSerializationRoundTripAsync()
+ {
+ // Arrange
+ List capturedMessagesFirstRun = [];
+ List capturedMessagesSecondRun = [];
+ int runCount = 0;
+ Mock mockService = new();
+ MessageInjectingChatClient? injectorRef = null;
+ ChatClientAgentSession? sessionRef = null;
+
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) =>
+ {
+ if (runCount == 1)
+ {
+ capturedMessagesFirstRun.AddRange(msgs);
+
+ // Inject a message during the first run — this will remain pending (not drained)
+ // because we return an actionable FCC that causes the parent loop to take over.
+ injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected before serialization")]);
+
+ // Return actionable FCC so the injection loop does NOT drain the message
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant,
+ [new FunctionCallContent("call1", "myTool", new Dictionary())])]));
+ }
+
+ // Second run (after deserialization) — capture what messages come through
+ capturedMessagesSecondRun.AddRange(msgs);
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
+ });
+
+ Mock mockChatHistoryProvider = new(null, null, null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatOptions = new() { Tools = [tool] },
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ RequirePerServiceCallChatHistoryPersistence = true,
+ EnableMessageInjection = true,
+ }, services: new ServiceCollection().BuildServiceProvider());
+
+ injectorRef = agent.ChatClient.GetService()!;
+
+ // Act — First run: inject a message that stays pending
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ sessionRef = session;
+ runCount = 1;
+ await agent.RunAsync([new(ChatRole.User, "first run message")], session);
+
+ // Serialize the session and deserialize into a new instance
+ var serialized = await agent.SerializeSessionAsync(session!);
+ var deserializedSession = await agent.DeserializeSessionAsync(serialized) as ChatClientAgentSession;
+
+ // Second run on the deserialized session — the injected message should be delivered
+ runCount = 2;
+ sessionRef = deserializedSession;
+ await agent.RunAsync([new(ChatRole.User, "second run message")], deserializedSession);
+
+ // Assert — the second run should include the injected message from before serialization
+ Assert.Contains(capturedMessagesSecondRun, m => m.Text == "injected before serialization");
+ Assert.Contains(capturedMessagesSecondRun, m => m.Text == "second run message");
+ }
+}