Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientVectorStoreTests.cs
Roger Barreto a12cc3878e .NET: Promote FoundryChatClient to public, add file/vector-store helpers and ToPromptAgentAsync converter (#5940)
* Consolidate Foundry chat client decorators into FoundryChatClient

- Replace AzureAIProjectChatClient and AzureAIProjectResponsesChatClient with a single internal sealed FoundryChatClient that covers three modes (pure responses, server-side agent reference, hosted agent endpoint).
- Rename AzureAIProjectChatClientExtensions to AIProjectClientExtensions to reflect that it extends AIProjectClient.
- All four AsAIAgent extension overloads and both FoundryAgent constructors now construct FoundryChatClient internally so the microsoft.foundry telemetry tag is uniform across paths.
- Introduce AgentFrameworkUserAgentPolicy that stamps agent-framework-dotnet/{version} on outbound requests, mirroring the Python agent-framework-python/{version} contract.
- Delete the Foundry-local MeaiUserAgentPolicy duplicate; rely on MEAI 10.5.1 to stamp MEAI/{version} automatically.
- HostedAgentUserAgentPolicy keeps the combined foundry-hosting/agent-framework-dotnet/{version} segment (Python parity) and upgrades the bare segment in place to avoid duplication.
- Tests reorganized: FoundryChatClientTests, AIProjectClientExtensionsTests, AgentFrameworkUserAgentPolicyTests, MeaiAutoUserAgentVerificationTests, plus in-place upgrade unit tests in HostedOutboundUserAgentTests.

* Promote FoundryChatClient to public; add file/vector-store helpers and ToPromptAgentAsync converter

- Promote FoundryChatClient from internal sealed to public sealed for Python parity, so .NET developers can hold and pass a FoundryChatClient directly the way Python developers do.
- Mode 3 (hosted agent endpoint) now materializes an AIProjectClient from the parsed project root, making GetService<AIProjectClient>() non-null across all three construction modes. This eliminates the per-mode asymmetry that previously hid project-level helpers from agents constructed via an agent endpoint URL.
- Add four new instance methods on FoundryChatClient mirroring Python's spec: UploadFileAsync, DeleteFileAsync, CreateVectorStoreAsync (bundles upload + create + wait), DeleteVectorStoreAsync. Single overload each, path-only inputs to start; additional overloads can be added later without breaking callers. All are Experimental, consistent with the rest of the Foundry package.
- Add ToPromptAgentAsync extension methods on ChatClientAgent and FoundryAgent for the agent-to-prompt-agent converter described in the Foundry spec. Mode 1 (responses API) synthesizes a DeclarativeAgentDefinition from the agent's ChatOptions; mode 2 (server-side agent reference, version, or record) returns the cached or freshly fetched Definition; mode 3 throws InvalidOperationException because no local definition exists to convert.
- Strict AITool to ResponseTool mapping for mode 1: AIFunction becomes CreateFunctionTool with the function's JSON schema; AITool instances that wrap a ResponseTool unwrap via GetService(typeof(ResponseTool)); anything else throws InvalidOperationException naming the offending tool type. Matches the Python spec's unsupported-tools-raise-ValueError contract.
- New unit tests: FoundryChatClientVectorStoreTests (22 tests covering all four helpers across the three FoundryChatClient construction modes plus validation and cancellation), FoundryPromptAgentConverterTests (16 tests covering both extension entry points across mode 1 synthesis, mode 2 cached and fetched paths, all failure modes, and a Python-parity guard asserting both extensions produce equivalent definitions for equivalent inputs), plus four new tests in FoundryChatClientTests for the mode 3 AIProjectClient materialization.

* Stop building duplicate ProjectOpenAIClient in FoundryAgent agent-endpoint ctor

After Plan #2's mode-3 AIProjectClient materialization, the inner FoundryChatClient already exposes a project-level AIProjectClient (via GetService) that internally provides the project-level ProjectOpenAIClient via GetProjectOpenAIClient(). FoundryAgent's agent-endpoint constructor was still independently constructing a second project-level ProjectOpenAIClient via the now-redundant CreateProjectLevelOpenAIClientFromAgentEndpoint helper — two handles to the same logical resource.

Refactor: the agent-endpoint constructor now reads the inner FoundryChatClient's materialized AIProjectClient via base.GetService(typeof(AIProjectClient)) and derives the project-level ProjectOpenAIClient from it. The dead helper on both FoundryAgent (private static wrapper) and FoundryChatClient (the actual implementation) is removed. The user-supplied per-agent ClientPipelineOptions primitives (Transport, RetryPolicy, NetworkTimeout, UserAgentApplicationId) are propagated into the materialized AIProjectClientOptions so test-injected transports and explicit retry / timeout / user-agent settings reach the project-level pipeline — preserving the behavior the dead helper used to provide.

Updated AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull to its now-correct counterpart AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNonNull, since after Plan #2 the agent-endpoint ctor surfaces a non-null AIProjectClient (per user direction in Plan #2 Q2).

* Strip duplicated AIProjectClient/ProjectOpenAIClient state from FoundryAgent

Both _aiProjectClient and _projectOpenAIClient fields on FoundryAgent were redundant:

- _aiProjectClient: FoundryAgent's GetService<AIProjectClient> override returned this field, but DelegatingAIAgent.GetService → ChatClientAgent.GetService → FoundryChatClient.GetService<AIProjectClient> already returns the same instance through the delegating chain. Field + override are pure duplication.

- _projectOpenAIClient: only used by FoundryAgent's own GetService<ProjectOpenAIClient> override and by CreateConversationSessionAsync. Per user direction, ProjectOpenAIClient is no longer exposed via GetService on either FoundryChatClient or FoundryAgent — callers retrieve it from the AIProjectClient themselves (aiProjectClient.GetProjectOpenAIClient()) the same way the framework does internally. This eliminates the mode-3 asymmetry where the chat client's stored ProjectOpenAIClient was per-agent (URL /agents/{name}/endpoint/protocols/openai) while the agent's was project-level.

Refactor:
- Delete both fields on FoundryAgent and the GetService override.
- Delete the ProjectOpenAIClient branch from FoundryChatClient.GetService.
- CreateConversationSessionAsync now resolves AIProjectClient at call time via this.GetService<AIProjectClient>() and derives the conversations client from it.
- Update FoundryChatClient tests that asserted on GetService<ProjectOpenAIClient> to assert Null (deliberate removal).
- Update FoundryAgent tests AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull and ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull to ...ReturnsNull, and rewrite AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient to look up AIProjectClient instead.

No production code (only tests) referenced GetService<ProjectOpenAIClient>, so this is a safe surface reduction. Net: 30 insertions, 61 deletions; FoundryAgent shrinks to a pure delegator with only the two convenience methods (CreateSessionAsync, CreateConversationSessionAsync) on top of the delegating chain.

* Rename FoundryChatClient.HostedAgentName to AgentName and populate it for mode 2

The previous name implied a mode 3 only property tied to the hosted-agent endpoint URL. Today only hosted endpoints surface this name, but conceptually an agent name exists for every server-side agent the client talks to. Renaming to AgentName makes the property general-purpose and ready for future modes where the same chat client may target other server-side agent shapes that are not necessarily 'hosted'.

Mode 2 (server-side agent reference) now mirrors AgentReference.Name into AgentName so callers have a uniform handle regardless of construction mode:

* Mode 1 (pure responses): AgentName is null. There is no agent.
* Mode 2 (AgentReference): AgentName == AgentReference.Name.
* Mode 3 (agent endpoint URL): AgentName is parsed from the URL segment as before.

Converter discriminator update: FoundryPromptAgentConverter previously used 'HostedAgentName is not null' to detect mode 3 and reject it. Now that mode 2 also populates AgentName, the mode 3 guard moves to the end of the resolution chain and uses the unambiguous 'AgentName is set AND no AgentReference exists' test. The user-visible error message and behavior are preserved.

Dead-state cleanup spotted during format verify:

* IDE0052 surfaced that FoundryChatClient._projectOpenAIClient is never read since the prior refactor stopped exposing ProjectOpenAIClient via GetService and rewired CreateConversationSessionAsync to resolve the AIProjectClient through the delegating chain. The field is deleted and its three ctor assignments removed.
* HostedAgentEndpointInner.PerAgentClient only existed to plumb the per-agent ProjectOpenAIClient into that now-deleted field, so the property and its ctor parameter are removed. The local 'perAgentClient' variable inside BuildHostedAgentEndpointInner is still needed to derive the inner IChatClient, but no longer escapes the helper.

Tests:

* Mode1_PureResponses_ReturnsNullForAgentSpecificServices now also asserts AgentName is null.
* New Mode2_AgentReference_PopulatesAgentNameFromAgentReference asserts the mode 2 mirror.
* Mode3_HostedAgentEndpoint_ParsesAgentNameFromUrl renamed assertion target HostedAgentName to AgentName.

Verification: 335/335 net10.0, 273/273 net472 Foundry unit; 229/229 Foundry.Hosting unit; format-verify (WSL2 + Docker mcr.microsoft.com/dotnet/sdk:10.0) clean on Microsoft.Agents.AI.Foundry.

* Adopt canonical mode names: Responses Agent, Prompt Agent, Agent Endpoint

Three FoundryChatClient construction modes now have one canonical noun used everywhere.

* Responses Agent (Mode 1): inline ChatClientAgent, project-level Responses API, no server-side def.
* Prompt Agent (Mode 2): server-side ProjectsAgentDefinition invoked by AgentReference.
* Agent Endpoint (Mode 3): per-agent URL /agents/{name}/endpoint/protocols/openai. Hosted-or-not.

'Hosted' stays the kind of agent (Microsoft.Agents.AI.Foundry.Hosting). Not synonym of Mode 3.

Rings:
1. XML docs + error messages use canonical names. en-GB to en-US: centralises, synthesise.
2. HostedAgentEndpointInner -> AgentEndpointInner, BuildHostedAgentEndpointInner -> BuildAgentEndpointInner.
3. Tests: Mode1_PureResponses_* -> Mode1_ResponsesAgent_*, Mode2_AgentReference_* -> Mode2_PromptAgent_*, Mode3_HostedAgentEndpoint_* -> Mode3_AgentEndpoint_*.

Pure rename. No behavior change. 335/335 net10 + 273/273 net472 unit, format clean.

* Address PR #5940 design feedback (Q-A through Q-F)

Q-A: poll vector store til status leaves InProgress before return. Exp backoff 250ms-2s. Honor cancel.
Q-B: try/catch upload loop. Mid-fail = best-effort DeleteFileAsync on already-uploaded ids. Swallow cleanup errors.
Q-C: pinned AgentReference.Version uses GetAgentVersionAsync. Empty/whitespace/'latest' = GetLatest path.
Q-D: HostedAgentUserAgentPolicy detects existing combined 'foundry-hosting/...' segment. No double prefix.
Q-E: mode-3 vector-store test uses fake transport. No DNS to example.com.
Q-F: no shim. Class always [Experimental] (since 8015e00f5, before dotnet-1.0.0). No compat contract. Callers rename to AIProjectClientExtensions.

Rebase onto origin/main reconciliation: aad20c2b3 added public AsAIAgent(this AIProjectClient, Uri agentEndpoint, ...) extension that calls an internal FoundryAgent(AIProjectClient, Uri, ...) ctor. Reintroduced that ctor + a new FoundryChatClient(AIProjectClient, Uri, ProjectOpenAIClientOptions?) overload that reuses the supplied AIProjectClient's pipeline (via GetProjectResponsesClientForAgentEndpoint) instead of stamping a fresh credential.

Verified: 346/346 net10 + 284/284 net472 Foundry unit, 230/230 Foundry.Hosting unit, format clean.

* Add FoundryAgent helper extensions: UploadFile/DeleteFile/CreateVectorStore/DeleteVectorStore

4 thin forwarders on FoundryAgent that route to the inner FoundryChatClient's helpers via agent.GetService<FoundryChatClient>().X(). Live in existing FoundryAgentExtensions.cs alongside ToPromptAgentAsync.

Throws InvalidOperationException when agent does not expose a FoundryChatClient via GetService (same pattern as ToPromptAgentAsync).

Unit tests: FoundryAgentExtensionsTests covers all 4 forwarders + null-agent ArgumentNullException for each. 8 new tests, 354/354 net10 + 292/292 net472.

Integration tests: parallel FoundryAgentExtensionsTests under Foundry.IntegrationTests mirrors the existing CreateAgent_CreatesAgentWithVectorStoresAsync shape (upload -> create vector store -> FileSearch tool answers question -> cleanup), but routes every helper call through the new FoundryAgent extensions. 4 new IT tests, all verified pass live against the real Foundry project (12-30s each). Skipped by default like the existing vector-store IT.

* Address Sergey's PR review comments

#1 (FoundryAgent.cs:139): drop unused aiProjectClient param from internal FoundryAgent(AIProjectClient, ChatClientAgent) ctor. Was discarded after null-check. Inner FoundryChatClient already surfaces AIProjectClient via GetService. 3 call sites in AIProjectClientExtensions updated.

#2 (FoundryChatClient.cs:376): add pollingTimeout param to CreateVectorStoreAsync. Defaults to 5 min, configurable, Timeout.InfiniteTimeSpan disables. Throws TimeoutException with vector store id and elapsed seconds when bound exceeded. CancellationToken still wins. New unit test PollingTimeout_ThrowsTimeoutExceptionAsync. FoundryAgentExtensions forwarder updated to plumb the new param.

Verified: 355/355 net10 + 293/293 net472 Foundry unit, 230/230 Foundry.Hosting unit, format clean.
2026-05-21 10:05:58 +00:00

661 lines
29 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using OpenAI.Files;
#pragma warning disable OPENAI001, CS0618
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for the file and vector-store helper methods on <see cref="FoundryChatClient"/>.
/// Covers all four methods across the three FoundryChatClient construction modes plus argument
/// validation, cancellation, and request-body shape on the wire.
/// </summary>
public sealed class FoundryChatClientVectorStoreTests
{
// ----- Construction helpers shared by every test in this file -----
private static (FoundryChatClient ChatClient, RequestRecorder Recorder) CreateMode1(string modelId = "gpt-4o-mini", string? responseBody = null)
{
var recorder = new RequestRecorder(responseBody);
#pragma warning disable CA5399
var httpClient = new HttpClient(recorder);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
return (new FoundryChatClient(projectClient, modelId), recorder);
}
private static (FoundryChatClient ChatClient, RequestRecorder Recorder) CreateMode2(string? responseBody = null)
{
var recorder = new RequestRecorder(responseBody);
#pragma warning disable CA5399
var httpClient = new HttpClient(recorder);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var agentRef = new AgentReference("agent-name", "1");
return (new FoundryChatClient(projectClient, agentRef, defaultModelId: "gpt-4o", baseChatOptions: null), recorder);
}
private static string MakeTempFile(string contents = "hello world")
{
var path = Path.Combine(Path.GetTempPath(), $"fcc-test-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, contents);
return path;
}
// ----- UploadFileAsync -----
[Fact]
public async Task UploadFileAsync_Mode1_UploadsViaProjectOpenAIClientAsync()
{
var (chatClient, recorder) = CreateMode1(responseBody: FakeFileJson("file_abc"));
var path = MakeTempFile();
try
{
var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants);
Assert.Equal("file_abc", result.Id);
Assert.NotEmpty(recorder.Requests);
Assert.EndsWith("/files", recorder.Requests[0].PathAndQuery.TrimEnd('/').Split('?')[0]);
}
finally { File.Delete(path); }
}
[Fact]
public async Task UploadFileAsync_Mode2_UploadsViaProjectOpenAIClientAsync()
{
var (chatClient, recorder) = CreateMode2(responseBody: FakeFileJson("file_xyz"));
var path = MakeTempFile();
try
{
var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants);
Assert.Equal("file_xyz", result.Id);
Assert.Contains(recorder.Requests, r => r.PathAndQuery.Contains("/files"));
}
finally { File.Delete(path); }
}
[Fact]
public async Task UploadFileAsync_Mode3_UploadsViaMaterializedProjectClientAsync()
{
// Q-E: Mode 3 (Agent Endpoint) now honors caller-supplied transports via
// ProjectOpenAIClientOptions.Transport, so we can use a fake transport here instead of
// depending on DNS/network availability against example.com.
var sawUpload = false;
using var handler = new HttpHandlerAssert(req =>
{
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
{
sawUpload = true;
return MakeJsonResponse(FakeFileJson("file_mode3"));
}
return MakeJsonResponse("{}");
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var chatClient = new FoundryChatClient(
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
credential: new FakeAuthenticationTokenProvider(),
clientOptions: new ProjectOpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var path = MakeTempFile();
try
{
var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants, CancellationToken.None);
Assert.True(sawUpload);
Assert.Equal("file_mode3", result.Id);
}
finally { File.Delete(path); }
}
[Fact]
public async Task UploadFileAsync_NullFilePath_ThrowsArgumentNullExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAsync<ArgumentNullException>(() =>
chatClient.UploadFileAsync(null!, FileUploadPurpose.Assistants));
}
[Fact]
public async Task UploadFileAsync_FileNotFound_ThrowsFileNotFoundExceptionAsync()
{
var (chatClient, _) = CreateMode1();
var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
await Assert.ThrowsAsync<FileNotFoundException>(() =>
chatClient.UploadFileAsync(missing, FileUploadPurpose.Assistants));
}
[Fact]
public async Task UploadFileAsync_HonorsCancellationAsync()
{
// Cancellation propagation through the OpenAI SDK pipeline surfaces different exception
// types depending on the framework target (OperationCanceledException on net10.0,
// ObjectDisposedException at the transport layer on net472). Asserting on the exact
// exception class is brittle; assert only that the call throws when the token is
// pre-cancelled.
var (chatClient, _) = CreateMode1(responseBody: FakeFileJson("file_abc"));
var path = MakeTempFile();
try
{
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<Exception>(() =>
chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants, cts.Token));
}
finally { File.Delete(path); }
}
// ----- DeleteFileAsync -----
[Fact]
public async Task DeleteFileAsync_Mode1_CallsDeleteOnFileClientAsync()
{
var (chatClient, recorder) = CreateMode1(responseBody: FakeFileDeletedJson("file_abc"));
await chatClient.DeleteFileAsync("file_abc");
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/files/file_abc"));
}
[Fact]
public async Task DeleteFileAsync_Mode2_CallsDeleteOnFileClientAsync()
{
var (chatClient, recorder) = CreateMode2(responseBody: FakeFileDeletedJson("file_xyz"));
await chatClient.DeleteFileAsync("file_xyz");
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/files/file_xyz"));
}
[Fact]
public async Task DeleteFileAsync_NullId_ThrowsArgumentExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAnyAsync<ArgumentException>(() => chatClient.DeleteFileAsync(null!));
}
[Fact]
public async Task DeleteFileAsync_EmptyId_ThrowsArgumentExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAnyAsync<ArgumentException>(() => chatClient.DeleteFileAsync(""));
}
[Fact]
public async Task DeleteFileAsync_HonorsCancellationAsync()
{
// Verify the cancellation token reaches the HTTP pipeline by having the handler
// throw OperationCanceledException when the token is cancelled before the request.
// This is more robust than asserting on the exact exception the SDK surfaces, which
// depends on internal pipeline plumbing.
var observedToken = CancellationToken.None;
using var handler = new HttpHandlerAssert(async req =>
{
// We don't have direct access to the SDK's CancellationToken here; instead, sleep
// briefly to give the caller's pre-cancellation a chance to be picked up by the
// transport. If cancellation reached the pipeline, the await on this handler call
// would surface OperationCanceledException; if not, the response is returned.
await Task.Delay(50).ConfigureAwait(false);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(FakeFileDeletedJson("file_abc"), Encoding.UTF8, "application/json"),
};
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
using var cts = new CancellationTokenSource();
cts.Cancel();
// Any throw is acceptable evidence that cancellation was honored. The SDK's exact
// exception surface for pre-cancelled tokens is an implementation detail of
// System.ClientModel's pipeline and may differ between versions.
await Assert.ThrowsAnyAsync<Exception>(() => chatClient.DeleteFileAsync("file_abc", cts.Token));
}
// ----- CreateVectorStoreAsync -----
[Fact]
public async Task CreateVectorStoreAsync_UploadsThenCreates_WithFileIds_ReturnsVectorStoreAsync()
{
// Each file POST returns a distinct file id; the recorder dispatches on URL to differentiate.
var fileCount = 0;
using var handler = new HttpHandlerAssert(async req =>
{
var body = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false);
if (req.RequestUri!.AbsolutePath.Contains("/files") && req.Method == HttpMethod.Post)
{
fileCount++;
return MakeJsonResponse(FakeFileJson($"file_{fileCount}"));
}
if (req.RequestUri.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
{
Assert.Contains("file_1", body);
Assert.Contains("file_2", body);
Assert.Contains("knowledge-base", body);
return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "knowledge-base"));
}
return MakeJsonResponse("{}");
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
var pathA = MakeTempFile("alpha");
var pathB = MakeTempFile("beta");
try
{
var store = await chatClient.CreateVectorStoreAsync("knowledge-base", new[] { pathA, pathB });
Assert.Equal("vs_abc", store.Id);
Assert.Equal(2, fileCount);
}
finally { File.Delete(pathA); File.Delete(pathB); }
}
[Fact]
public async Task CreateVectorStoreAsync_WithExpiresAfter_SerializesLastActiveAtAnchorAsync()
{
string? vectorStoreBody = null;
using var handler = new HttpHandlerAssert(async req =>
{
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
{
vectorStoreBody = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false);
return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "x"));
}
return MakeJsonResponse("{}");
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
await chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), expiresAfter: TimeSpan.FromDays(7));
Assert.NotNull(vectorStoreBody);
Assert.Contains("\"expires_after\"", vectorStoreBody);
Assert.Contains("\"last_active_at\"", vectorStoreBody);
Assert.Contains("\"days\":7", vectorStoreBody);
}
[Fact]
public async Task CreateVectorStoreAsync_WithNullExpiresAfter_OmitsExpirationPolicyAsync()
{
string? vectorStoreBody = null;
using var handler = new HttpHandlerAssert(async req =>
{
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
{
vectorStoreBody = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false);
return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "x"));
}
return MakeJsonResponse("{}");
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
await chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), expiresAfter: null);
Assert.NotNull(vectorStoreBody);
Assert.DoesNotContain("\"expires_after\"", vectorStoreBody);
}
[Fact]
public async Task CreateVectorStoreAsync_EmptyFilesList_CreatesEmptyStoreAsync()
{
var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreJson("vs_empty", name: "x"));
var store = await chatClient.CreateVectorStoreAsync("x", Array.Empty<string>());
Assert.Equal("vs_empty", store.Id);
}
[Fact]
public async Task CreateVectorStoreAsync_NullName_ThrowsArgumentExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
chatClient.CreateVectorStoreAsync(null!, Array.Empty<string>()));
}
[Fact]
public async Task CreateVectorStoreAsync_NullFilePaths_ThrowsArgumentNullExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAsync<ArgumentNullException>(() =>
chatClient.CreateVectorStoreAsync("x", filePaths: null!));
}
[Fact]
public async Task CreateVectorStoreAsync_HonorsCancellationAsync()
{
// Same rationale as UploadFileAsync_HonorsCancellationAsync — assert only that any
// exception is thrown on a pre-cancelled token.
var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreJson("vs_x", "x"));
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<Exception>(() =>
chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), expiresAfter: null, cancellationToken: cts.Token));
}
[Fact]
public async Task CreateVectorStoreAsync_PollsUntilStoreLeavesInProgress_Async()
{
// Q-A regression: when the create response returns status=in_progress, the helper must
// poll GET /vector_stores/{id} until status changes before returning. Otherwise the
// caller receives a half-built store.
var pollCount = 0;
using var handler = new HttpHandlerAssert(req =>
{
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
{
// First response: status=in_progress.
return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_abc", name: "x", status: "in_progress")));
}
if (req.RequestUri.AbsolutePath.Contains("/vector_stores/vs_abc") && req.Method == HttpMethod.Get)
{
pollCount++;
// Stay in_progress for two polls, then complete on the third.
var status = pollCount < 3 ? "in_progress" : "completed";
return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_abc", name: "x", status: status)));
}
return Task.FromResult(MakeJsonResponse("{}"));
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
var store = await chatClient.CreateVectorStoreAsync("x", Array.Empty<string>());
Assert.NotEqual(OpenAI.VectorStores.VectorStoreStatus.InProgress, store.Status);
Assert.True(pollCount >= 3, $"Expected at least 3 GET polls before status leaves in_progress; saw {pollCount}.");
}
[Fact]
public async Task CreateVectorStoreAsync_PollingTimeout_ThrowsTimeoutExceptionAsync()
{
// Sergey #2: caller-supplied (or default) polling timeout must surface as TimeoutException
// when the vector store never leaves InProgress. Mock keeps the store stuck and we pass
// a tiny timeout; cancellation token stays unused so the only path that ends the loop
// is the timeout check.
using var handler = new HttpHandlerAssert(req =>
{
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores", StringComparison.Ordinal))
{
return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_stuck", name: "x", status: "in_progress")));
}
return Task.FromResult(MakeJsonResponse("{}"));
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
var ex = await Assert.ThrowsAsync<TimeoutException>(() =>
chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), expiresAfter: null, pollingTimeout: TimeSpan.FromMilliseconds(500)));
Assert.Contains("vs_stuck", ex.Message, StringComparison.Ordinal);
Assert.Contains("in-progress", ex.Message, StringComparison.Ordinal);
}
[Fact]
public async Task CreateVectorStoreAsync_MidUploadFailure_DeletesAlreadyUploadedFilesAsync()
{
// Q-B regression: when the upload loop throws partway through (e.g. file 3 of 5 is
// missing or the network fails), the helper must DELETE the already-uploaded files so
// they do not accumulate as orphaned resources. The exception must still propagate.
var uploadCount = 0;
var deleted = new List<string>();
using var handler = new HttpHandlerAssert(req =>
{
// DELETE first so we don't match the upload-collection /files path against this.
if (req.Method == HttpMethod.Delete)
{
var segments = req.RequestUri!.AbsolutePath.Split('/');
var fileId = segments[segments.Length - 1];
deleted.Add(fileId);
return MakeJsonResponse(FakeFileDeletedJson(fileId));
}
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
{
uploadCount++;
if (uploadCount == 3)
{
// 400 is non-retriable; the SDK retry policy ignores it. 5xx would trigger
// retries and confuse the assertion on upload count.
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("{\"error\":{\"code\":\"BadRequest\",\"message\":\"upload-failed-on-3\"}}", Encoding.UTF8, "application/json"),
};
}
return MakeJsonResponse(FakeFileJson($"file_{uploadCount}"));
}
return MakeJsonResponse("{}");
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
var paths = new[] { MakeTempFile("a"), MakeTempFile("b"), MakeTempFile("c"), MakeTempFile("d"), MakeTempFile("e") };
try
{
await Assert.ThrowsAnyAsync<Exception>(() => chatClient.CreateVectorStoreAsync("knowledge-base", paths));
// Three upload attempts: two succeeded, the third threw.
Assert.Equal(3, uploadCount);
// The two successful uploads must have been deleted as part of best-effort cleanup.
Assert.Equal(2, deleted.Count);
Assert.Contains("file_1", deleted);
Assert.Contains("file_2", deleted);
}
finally
{
foreach (var p in paths)
{
File.Delete(p);
}
}
}
[Fact]
public async Task CreateVectorStoreAsync_MidUploadFailure_CleanupSwallowsDeleteErrorsAsync()
{
// Q-B follow-on: if a cleanup DELETE itself fails, the helper must still propagate the
// original upload exception — not the cleanup exception. The caller cares about the
// upload failure; cleanup is best-effort.
var uploadCount = 0;
using var handler = new HttpHandlerAssert(req =>
{
if (req.Method == HttpMethod.Delete)
{
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("{\"error\":{\"code\":\"DeleteFailed\",\"message\":\"cleanup-failed\"}}", Encoding.UTF8, "application/json"),
};
}
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
{
uploadCount++;
if (uploadCount == 2)
{
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("{\"error\":{\"code\":\"BadRequest\",\"message\":\"upload-failed\"}}", Encoding.UTF8, "application/json"),
};
}
return MakeJsonResponse(FakeFileJson($"file_{uploadCount}"));
}
return MakeJsonResponse("{}");
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
var paths = new[] { MakeTempFile("a"), MakeTempFile("b") };
try
{
var ex = await Assert.ThrowsAnyAsync<Exception>(() => chatClient.CreateVectorStoreAsync("kb", paths));
// The original upload-failure message must surface, not the cleanup-failure message.
Assert.DoesNotContain("cleanup-failed", ex.Message ?? "", StringComparison.Ordinal);
}
finally
{
foreach (var p in paths)
{
File.Delete(p);
}
}
}
// ----- DeleteVectorStoreAsync -----
[Fact]
public async Task DeleteVectorStoreAsync_Mode1_CallsDeleteAsync()
{
var (chatClient, recorder) = CreateMode1(responseBody: FakeVectorStoreDeletedJson("vs_abc"));
await chatClient.DeleteVectorStoreAsync("vs_abc");
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/vector_stores/vs_abc"));
}
[Fact]
public async Task DeleteVectorStoreAsync_Mode2_CallsDeleteAsync()
{
var (chatClient, recorder) = CreateMode2(responseBody: FakeVectorStoreDeletedJson("vs_xyz"));
await chatClient.DeleteVectorStoreAsync("vs_xyz");
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/vector_stores/vs_xyz"));
}
[Fact]
public async Task DeleteVectorStoreAsync_NullId_ThrowsArgumentExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAnyAsync<ArgumentException>(() => chatClient.DeleteVectorStoreAsync(null!));
}
[Fact]
public async Task DeleteVectorStoreAsync_HonorsCancellationAsync()
{
// Same approach as DeleteFileAsync_HonorsCancellationAsync — assert that the call
// throws when the token is pre-cancelled, without asserting on the exact exception
// surfaced by the SDK pipeline.
var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreDeletedJson("vs_abc"));
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<Exception>(() => chatClient.DeleteVectorStoreAsync("vs_abc", cts.Token));
}
// ----- Fixtures and helpers -----
private static HttpResponseMessage MakeJsonResponse(string json)
=> new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
private static string FakeFileJson(string id)
=> $"{{\"id\":\"{id}\",\"object\":\"file\",\"bytes\":11,\"created_at\":1700000000,\"filename\":\"x.txt\",\"purpose\":\"assistants\",\"status\":\"processed\"}}";
private static string FakeFileDeletedJson(string id)
=> $"{{\"id\":\"{id}\",\"object\":\"file\",\"deleted\":true}}";
private static string FakeVectorStoreJson(string id, string name)
=> FakeVectorStoreJsonWithStatus(id, name, status: "completed");
private static string FakeVectorStoreJsonWithStatus(string id, string name, string status)
=> $"{{\"id\":\"{id}\",\"object\":\"vector_store\",\"created_at\":1700000000,\"name\":\"{name}\",\"usage_bytes\":0,\"file_counts\":{{\"in_progress\":0,\"completed\":0,\"failed\":0,\"cancelled\":0,\"total\":0}},\"status\":\"{status}\",\"last_active_at\":1700000000}}";
private static string FakeVectorStoreDeletedJson(string id)
=> $"{{\"id\":\"{id}\",\"object\":\"vector_store.deleted\",\"deleted\":true}}";
private sealed class RequestRecorder : HttpClientHandler
{
private readonly string _responseBody;
public List<RecordedRequest> Requests { get; } = [];
public RequestRecorder(string? responseBody)
{
this._responseBody = responseBody ?? "{}";
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.Requests.Add(new RecordedRequest
{
Method = request.Method.Method,
PathAndQuery = request.RequestUri?.PathAndQuery ?? "",
#if NET
Body = request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false),
#else
Body = request.Content is null ? "" : await request.Content.ReadAsStringAsync().ConfigureAwait(false),
#endif
});
return MakeJsonResponse(this._responseBody);
}
}
private sealed class RecordedRequest
{
public string Method { get; set; } = "";
public string PathAndQuery { get; set; } = "";
public string Body { get; set; } = "";
}
}
#pragma warning restore CS0618