chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end integration test for the Hosted-Files style scenario: a file uploaded by the client
|
||||
/// via the alpha <see cref="AgentSessionFiles"/> SDK is read by the deployed hosted agent's
|
||||
/// container-side <c>ReadFile</c> tool and surfaces in <see cref="AIAgent.RunAsync(string, AgentSession, AgentRunOptions, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Routing both invocations to the same per-session container requires two clients on the same
|
||||
/// agent-scoped <see cref="ProjectOpenAIClient"/>: a <see cref="ProjectConversationsClient"/> to
|
||||
/// pre-create a conversation bound to the agent endpoint, and a <see cref="ProjectResponsesClient"/>
|
||||
/// for invocation. The session id resolved by the platform on the first call is captured from the
|
||||
/// <c>x-agent-session-id</c> response header and used to target the
|
||||
/// <see cref="AgentSessionFiles"/> upload at the same session's <c>$HOME</c>. The second call
|
||||
/// carries the same conversation_id so it lands in the same container and the agent's
|
||||
/// <c>ReadFile</c> tool sees the upload.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class SessionFilesHostedAgentTests(SessionFilesHostedAgentFixture fixture) : IClassFixture<SessionFilesHostedAgentFixture>
|
||||
{
|
||||
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";
|
||||
|
||||
/// <summary>Token that appears verbatim in the test data file. Proof the agent read what we uploaded.</summary>
|
||||
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);
|
||||
|
||||
// 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.");
|
||||
|
||||
// AgentSessionFiles is scoped to the (agent, session) pair at creation time.
|
||||
var sessionFiles = adminClient.GetAgentSessionFiles(this._fixture.AgentName, agentSessionId);
|
||||
|
||||
try
|
||||
{
|
||||
// Step 3 — upload the file via the alpha AgentSessionFiles SDK to that exact session's $HOME.
|
||||
SessionFileWriteResponse writeResponse = await sessionFiles.UploadAsync(
|
||||
sessionStoragePath: TestDataFileName,
|
||||
localPath: localPath);
|
||||
|
||||
long expectedBytes = new FileInfo(localPath).Length;
|
||||
Assert.Equal(expectedBytes, writeResponse.BytesWritten);
|
||||
|
||||
bool foundEntry = false;
|
||||
await foreach (SessionDirectoryEntry entry in sessionFiles.GetAllAsync(
|
||||
sessionStoragePath: "."))
|
||||
{
|
||||
if (entry.Name == TestDataFileName && !entry.IsDirectory && entry.SizeInBytes == expectedBytes)
|
||||
{
|
||||
foundEntry = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.True(
|
||||
foundEntry,
|
||||
$"Expected session directory listing to contain '{TestDataFileName}' ({expectedBytes} bytes) as a file.");
|
||||
|
||||
// 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<ResponseResult> 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.DeleteAsync(TestDataFileName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._fixture.DeleteConversationAsync(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures a response header value on every pipeline call. Latest value is read after the
|
||||
/// response completes. Used to grab the platform's <c>x-agent-session-id</c> stamp.
|
||||
/// </summary>
|
||||
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<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
this.Capture(message);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> 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<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
this.SetHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user