// 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); } } }