, or , since the chat UI does not render HTML.",
+ },
+ AIContextProviders = [cu],
+ });
+});
+
+builder.Services.AddOpenAIResponses();
+builder.Services.AddOpenAIConversations();
+builder.AddDevUI();
+
+var app = builder.Build();
+
+// HACK: Microsoft.Agents.AI.Hosting.OpenAI's ItemContentConverter passes raw base64 from
+// input_file.file_data straight into DataContent(string uri, ...), which requires a
+// "data:" URI and throws ArgumentException otherwise. Until that's fixed upstream,
+// rewrite incoming /v1/responses bodies so raw base64 is wrapped in a data: URI. The
+// Content Understanding provider's MimeSniffer then detects the real media type
+// (PDF / PNG / JPEG / WAV / MP3 / MP4) from the bytes.
+app.Use(static async (ctx, next) =>
+{
+ if (HttpMethods.IsPost(ctx.Request.Method)
+ && ctx.Request.Path.StartsWithSegments("/v1/responses")
+ && (ctx.Request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) ?? false))
+ {
+ ctx.Request.EnableBuffering();
+ string body;
+ using (var reader = new StreamReader(ctx.Request.Body, Encoding.UTF8, leaveOpen: true))
+ {
+ body = await reader.ReadToEndAsync().ConfigureAwait(false);
+ }
+ ctx.Request.Body.Position = 0;
+
+ if (ResponsesRawBase64Workaround.TryRewrite(body, out string rewritten))
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(rewritten);
+ ctx.Request.Body = new MemoryStream(bytes);
+ ctx.Request.ContentLength = bytes.Length;
+ }
+ }
+ await next().ConfigureAwait(false);
+});
+
+app.MapOpenAIResponses();
+app.MapOpenAIConversations();
+
+if (builder.Environment.IsDevelopment())
+{
+ app.MapDevUI();
+}
+
+Console.WriteLine("DevUI is available at: https://localhost:50520/devui");
+Console.WriteLine("OpenAI Responses API is available at: https://localhost:50520/v1/responses");
+Console.WriteLine("Press Ctrl+C to stop the server.");
+
+app.Run();
+
+///
+/// Wraps raw-base64 file_data fields in OpenAI Responses request bodies into data: URIs.
+/// Workaround for Microsoft.Agents.AI.Hosting.OpenAI's ItemContentConverter, which expects
+/// a data: URI form. Drop this once the upstream package handles raw base64 directly.
+///
+internal static class ResponsesRawBase64Workaround
+{
+ public static bool TryRewrite(string body, out string rewritten)
+ {
+ rewritten = body;
+ if (string.IsNullOrEmpty(body))
+ {
+ return false;
+ }
+
+ // A Try* method must never throw: a malformed body (the content-type header can lie)
+ // would otherwise bubble a JsonException out of the middleware and 500 the request —
+ // including requests that need no rewriting. On parse failure, leave the body untouched
+ // and let the downstream endpoint handle (and properly reject) it.
+ JsonDocument doc;
+ try
+ {
+ doc = JsonDocument.Parse(body);
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+
+ using (doc)
+ {
+ if (!ContainsRawFileData(doc.RootElement))
+ {
+ return false;
+ }
+
+ using MemoryStream stream = new();
+ using (Utf8JsonWriter writer = new(stream))
+ {
+ RewriteElement(doc.RootElement, writer);
+ }
+ rewritten = Encoding.UTF8.GetString(stream.ToArray());
+ return true;
+ }
+ }
+
+ private static bool ContainsRawFileData(JsonElement element)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ if (IsInputFile(element)
+ && element.TryGetProperty("file_data", out JsonElement fileData)
+ && fileData.ValueKind == JsonValueKind.String
+ && fileData.GetString() is { Length: > 0 } s
+ && !s.StartsWith("data:", StringComparison.Ordinal))
+ {
+ return true;
+ }
+ foreach (JsonProperty prop in element.EnumerateObject())
+ {
+ if (ContainsRawFileData(prop.Value))
+ {
+ return true;
+ }
+ }
+ return false;
+ case JsonValueKind.Array:
+ foreach (JsonElement item in element.EnumerateArray())
+ {
+ if (ContainsRawFileData(item))
+ {
+ return true;
+ }
+ }
+ return false;
+ default:
+ return false;
+ }
+ }
+
+ private static bool IsInputFile(JsonElement element)
+ => element.TryGetProperty("type", out JsonElement t)
+ && t.ValueKind == JsonValueKind.String
+ && string.Equals(t.GetString(), "input_file", StringComparison.Ordinal);
+
+ private static void RewriteElement(JsonElement element, Utf8JsonWriter writer)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ writer.WriteStartObject();
+ bool inputFile = IsInputFile(element);
+ foreach (JsonProperty prop in element.EnumerateObject())
+ {
+ writer.WritePropertyName(prop.Name);
+ if (inputFile
+ && prop.Name == "file_data"
+ && prop.Value.ValueKind == JsonValueKind.String
+ && prop.Value.GetString() is { Length: > 0 } s
+ && !s.StartsWith("data:", StringComparison.Ordinal))
+ {
+ writer.WriteStringValue("data:application/octet-stream;base64," + s);
+ }
+ else
+ {
+ RewriteElement(prop.Value, writer);
+ }
+ }
+ writer.WriteEndObject();
+ break;
+ case JsonValueKind.Array:
+ writer.WriteStartArray();
+ foreach (JsonElement item in element.EnumerateArray())
+ {
+ RewriteElement(item, writer);
+ }
+ writer.WriteEndArray();
+ break;
+ case JsonValueKind.String:
+ writer.WriteStringValue(element.GetString());
+ break;
+ case JsonValueKind.Number:
+ writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
+ break;
+ case JsonValueKind.True:
+ writer.WriteBooleanValue(true);
+ break;
+ case JsonValueKind.False:
+ writer.WriteBooleanValue(false);
+ break;
+ case JsonValueKind.Null:
+ writer.WriteNullValue();
+ break;
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/01_MultimodalAgent/Properties/launchSettings.json b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/01_MultimodalAgent/Properties/launchSettings.json
new file mode 100644
index 0000000000..1de8e88a33
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/01_MultimodalAgent/Properties/launchSettings.json
@@ -0,0 +1,13 @@
+{
+ "profiles": {
+ "01_MultimodalAgent": {
+ "commandName": "Project",
+ "launchUrl": "devui",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:50520;http://localhost:50521"
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/01_MultimodalAgent/README.md b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/01_MultimodalAgent/README.md
new file mode 100644
index 0000000000..ad1a4b815f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/01_MultimodalAgent/README.md
@@ -0,0 +1,36 @@
+# DevUI Multi-Modal Agent
+
+Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding.
+
+## Setup
+
+1. Set environment variables:
+
+ ```sh
+ AZURE_AI_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/
+ AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4.1
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.services.ai.azure.com/
+ ```
+
+2. Log in with Azure CLI (the sample uses `DefaultAzureCredential`):
+
+ ```sh
+ az login
+ ```
+
+3. Run the sample:
+
+ ```sh
+ dotnet run
+ ```
+
+4. Open in a browser and start uploading files.
+
+## What You Can Do
+
+- **Upload PDFs** — including scanned/image-based PDFs that LLM vision struggles with
+- **Upload images** — handwritten notes, infographics, charts
+- **Upload audio** — meeting recordings, call center calls (transcription with speaker ID)
+- **Upload video** — product demos, training videos (frame extraction + transcription)
+- **Ask questions** across all uploaded documents
+- **Check status** — "which documents are ready?" uses the auto-registered `list_documents()` tool
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/AzureOpenAIBackend/AzureOpenAIBackend.csproj b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/AzureOpenAIBackend/AzureOpenAIBackend.csproj
new file mode 100644
index 0000000000..a91ddc5265
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/AzureOpenAIBackend/AzureOpenAIBackend.csproj
@@ -0,0 +1,26 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+
+ $(NoWarn);OPENAI001
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/AzureOpenAIBackend/Program.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/AzureOpenAIBackend/Program.cs
new file mode 100644
index 0000000000..3b48ac3d93
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/AzureOpenAIBackend/Program.cs
@@ -0,0 +1,321 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// DevUI File-Search Agent (Azure OpenAI backend) — CU extraction + file_search RAG.
+//
+// This sample hosts an Azure-OpenAI–backed agent behind the DevUI middleware
+// and wires the Content Understanding provider with the `FileSearchConfig.FromOpenAI`
+// backend. Upload large or multi-modal files in the browser; the provider:
+// 1. extracts markdown via CU (handles scanned PDFs, audio, video),
+// 2. uploads the extracted markdown to an Azure OpenAI vector store,
+// 3. surfaces the file_search tool on the agent's context for token-efficient RAG.
+//
+// The vector store is auto-expiring (`expires_after = 1 day, last_active_at`) so
+// inactive sample sessions are cleaned up automatically. The CU provider's
+// DisposeAsync deletes the per-file uploads at app shutdown.
+//
+// Environment variables:
+// AZURE_OPENAI_ENDPOINT — Azure OpenAI endpoint URL
+// AZURE_OPENAI_DEPLOYMENT_NAME — Chat-model deployment name (e.g. gpt-4.1)
+// AZURE_CONTENTUNDERSTANDING_ENDPOINT — Content Understanding endpoint URL
+//
+// Run:
+// dotnet run
+// Then open https://localhost:50522/devui in a browser.
+
+using System.Text;
+using System.Text.Json;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.AzureAI.ContentUnderstanding;
+using Microsoft.Agents.AI.DevUI;
+using Microsoft.Agents.AI.Hosting;
+using Microsoft.Extensions.AI;
+using OpenAI.Files;
+using OpenAI.VectorStores;
+
+var builder = WebApplication.CreateBuilder(args);
+
+string openAiEndpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
+string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
+string cuEndpoint = builder.Configuration["AZURE_CONTENTUNDERSTANDING_ENDPOINT"]
+ ?? throw new InvalidOperationException("AZURE_CONTENTUNDERSTANDING_ENDPOINT is not set.");
+
+// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+var credential = new DefaultAzureCredential();
+
+// 1. Build the Azure OpenAI client used both for chat and for vector store ops.
+// NOTE: We MUST route the chat client through Azure OpenAI's Responses API
+// (GetResponsesClient), not Chat Completions (GetChatClient), because the
+// server-side `file_search` hosted tool only exists on the Responses endpoint.
+// Going through Chat Completions silently drops the HostedFileSearchTool and
+// the model has no way to retrieve indexed content.
+var azureOpenAIClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential);
+#pragma warning disable OPENAI001 // ResponsesClient/AsIChatClient are evaluation-only in OpenAI 2.10 — required for hosted file_search on Azure OpenAI.
+var chatClient = azureOpenAIClient.GetResponsesClient().AsIChatClient(deploymentName);
+#pragma warning restore OPENAI001
+builder.Services.AddChatClient(chatClient);
+
+// 2. Create a vector store up-front (auto-expires after 1 day idle so abandoned
+// DevUI sessions don't accumulate storage cost). The CU provider uploads each
+// analyzed document into this store; the file_search tool reads from it.
+var vectorStoreClient = azureOpenAIClient.GetVectorStoreClient();
+var vectorStoreResult = await vectorStoreClient.CreateVectorStoreAsync(
+ new VectorStoreCreationOptions
+ {
+ Name = "devui_cu_file_search",
+ ExpirationPolicy = new VectorStoreExpirationPolicy(VectorStoreExpirationAnchor.LastActiveAt, days: 1),
+ });
+string vectorStoreId = vectorStoreResult.Value.Id;
+
+// 3. Build the file_search tool that the agent will use to query the vector store.
+HostedFileSearchTool fileSearchTool = new() { Inputs = [new HostedVectorStoreContent(vectorStoreId)] };
+
+// 4. CU provider with file_search wiring. Singleton — its lifecycle and any
+// background analyses span the lifetime of the web host. DisposeAsync runs
+// on app shutdown and deletes the files the provider uploaded.
+builder.Services.AddSingleton(_ => new ContentUnderstandingContextProvider(
+ new ContentUnderstandingContextProviderOptions(new Uri(cuEndpoint), credential)
+ {
+ // Foreground budget for both CU analysis polling AND vector-store upload polling.
+ // Sample workloads (multi-page PDFs) typically need 10–20 s CU + 5–15 s vector-store
+ // ingestion, so a 60 s budget covers the common case in a single turn. Longer media
+ // (audio/video) that exceeds this budget gets a rehydration token stored on the entry
+ // and resumes on the next turn; the upload then runs in that follow-up turn against a
+ // fresh budget.
+ MaxWait = TimeSpan.FromSeconds(60),
+
+ // DevUI's HostedAgentResponseExecutor creates a fresh AgentSession every
+ // turn, so per-session state would be lost. PerAgent keys state on the
+ // agent instance instead — fine here because each DevUI agent is single-
+ // user. Production multi-tenant hosts MUST keep the default PerSession.
+ StateScope = StateScope.PerAgent,
+
+ // NOTE: We cannot use FileSearchConfig.FromOpenAI(...) here because the default
+ // OpenAIFileSearchBackend uploads files with purpose=user_data, which Azure OpenAI
+ // rejects with `Invalid value for "purpose"`. Azure OpenAI's vector-store ingestion
+ // pipeline requires purpose=assistants. We compose the FileSearchConfig manually with
+ // an AzureOpenAIFileSearchBackend (defined below) that overrides Purpose accordingly.
+ FileSearchConfig = new FileSearchConfig
+ {
+ Backend = new AzureOpenAIFileSearchBackend(azureOpenAIClient),
+ VectorStoreId = vectorStoreId,
+ FileSearchTool = fileSearchTool,
+ },
+ }));
+
+const string AgentName = "FileSearchDocAgent";
+
+builder.AddAIAgent(AgentName, (sp, key) =>
+{
+ var cu = sp.GetRequiredService();
+ var client = sp.GetRequiredService();
+ return new ChatClientAgent(client, new ChatClientAgentOptions
+ {
+ Name = key,
+ ChatOptions = new ChatOptions
+ {
+ ModelId = deploymentName,
+ Instructions = "You are a helpful document analysis assistant with RAG capabilities. "
+ + "When a user uploads files, they are automatically analyzed using Azure Content Understanding "
+ + "and indexed in a vector store for efficient retrieval. "
+ + "Analysis takes time (seconds for documents, longer for audio/video) — if a document "
+ + "is still pending, let the user know and suggest they ask again shortly. "
+ + "You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. "
+ + "Multiple files can be uploaded and queried in the same conversation. "
+ + "When answering, cite specific content from the documents. "
+ + "Format all responses as GitHub-flavored Markdown. When presenting tabular data, "
+ + "use Markdown table syntax (| col1 | col2 |\\n|---|---|\\n| val1 | val2 |) — "
+ + "never emit raw HTML tags like , , or , since the chat UI does not render HTML.",
+ },
+ AIContextProviders = [cu],
+ });
+});
+
+builder.Services.AddOpenAIResponses();
+builder.Services.AddOpenAIConversations();
+builder.AddDevUI();
+
+var app = builder.Build();
+
+// HACK: Microsoft.Agents.AI.Hosting.OpenAI's ItemContentConverter passes raw base64 from
+// input_file.file_data straight into DataContent(string uri, ...), which requires a
+// "data:" URI and throws ArgumentException otherwise. Until that's fixed upstream,
+// rewrite incoming /v1/responses bodies so raw base64 is wrapped in a data: URI. The
+// Content Understanding provider's MimeSniffer then detects the real media type
+// (PDF / PNG / JPEG / WAV / MP3 / MP4) from the bytes.
+app.Use(static async (ctx, next) =>
+{
+ if (HttpMethods.IsPost(ctx.Request.Method)
+ && ctx.Request.Path.StartsWithSegments("/v1/responses")
+ && (ctx.Request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) ?? false))
+ {
+ ctx.Request.EnableBuffering();
+ string body;
+ using (var reader = new StreamReader(ctx.Request.Body, Encoding.UTF8, leaveOpen: true))
+ {
+ body = await reader.ReadToEndAsync().ConfigureAwait(false);
+ }
+ ctx.Request.Body.Position = 0;
+
+ if (ResponsesRawBase64Workaround.TryRewrite(body, out string rewritten))
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(rewritten);
+ ctx.Request.Body = new MemoryStream(bytes);
+ ctx.Request.ContentLength = bytes.Length;
+ }
+ }
+ await next().ConfigureAwait(false);
+});
+
+app.MapOpenAIResponses();
+app.MapOpenAIConversations();
+
+if (builder.Environment.IsDevelopment())
+{
+ app.MapDevUI();
+}
+
+Console.WriteLine($"DevUI is available at: https://localhost:50522/devui (vector store: {vectorStoreId})");
+Console.WriteLine("OpenAI Responses API is available at: https://localhost:50522/v1/responses");
+Console.WriteLine("Press Ctrl+C to stop the server.");
+
+app.Run();
+
+///
+/// Azure OpenAI–compatible file-search backend: identical to
+/// but uploads files with instead of
+/// UserData. Azure OpenAI's /files endpoint rejects user_data with
+/// Invalid value for "purpose", so the stock FileSearchConfig.FromOpenAI
+/// factory cannot be used against Azure OpenAI vector stores.
+///
+internal sealed class AzureOpenAIFileSearchBackend : OpenAICompatFileSearchBackendBase
+{
+ public AzureOpenAIFileSearchBackend(AzureOpenAIClient client) : base(client) { }
+
+ protected override FileUploadPurpose Purpose => FileUploadPurpose.Assistants;
+}
+
+///
+/// Wraps raw-base64 file_data fields in OpenAI Responses request bodies into data: URIs.
+/// Workaround for Microsoft.Agents.AI.Hosting.OpenAI's ItemContentConverter, which expects
+/// a data: URI form. Drop this once the upstream package handles raw base64 directly.
+///
+internal static class ResponsesRawBase64Workaround
+{
+ public static bool TryRewrite(string body, out string rewritten)
+ {
+ rewritten = body;
+ if (string.IsNullOrEmpty(body))
+ {
+ return false;
+ }
+
+ using JsonDocument doc = JsonDocument.Parse(body);
+ if (!ContainsRawFileData(doc.RootElement))
+ {
+ return false;
+ }
+
+ using MemoryStream stream = new();
+ using (Utf8JsonWriter writer = new(stream))
+ {
+ RewriteElement(doc.RootElement, writer);
+ }
+ rewritten = Encoding.UTF8.GetString(stream.ToArray());
+ return true;
+ }
+
+ private static bool ContainsRawFileData(JsonElement element)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ if (IsInputFile(element)
+ && element.TryGetProperty("file_data", out JsonElement fileData)
+ && fileData.ValueKind == JsonValueKind.String
+ && fileData.GetString() is { Length: > 0 } s
+ && !s.StartsWith("data:", StringComparison.Ordinal))
+ {
+ return true;
+ }
+ foreach (JsonProperty prop in element.EnumerateObject())
+ {
+ if (ContainsRawFileData(prop.Value))
+ {
+ return true;
+ }
+ }
+ return false;
+ case JsonValueKind.Array:
+ foreach (JsonElement item in element.EnumerateArray())
+ {
+ if (ContainsRawFileData(item))
+ {
+ return true;
+ }
+ }
+ return false;
+ default:
+ return false;
+ }
+ }
+
+ private static bool IsInputFile(JsonElement element)
+ => element.TryGetProperty("type", out JsonElement t)
+ && t.ValueKind == JsonValueKind.String
+ && string.Equals(t.GetString(), "input_file", StringComparison.Ordinal);
+
+ private static void RewriteElement(JsonElement element, Utf8JsonWriter writer)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ writer.WriteStartObject();
+ bool inputFile = IsInputFile(element);
+ foreach (JsonProperty prop in element.EnumerateObject())
+ {
+ writer.WritePropertyName(prop.Name);
+ if (inputFile
+ && prop.Name == "file_data"
+ && prop.Value.ValueKind == JsonValueKind.String
+ && prop.Value.GetString() is { Length: > 0 } s
+ && !s.StartsWith("data:", StringComparison.Ordinal))
+ {
+ writer.WriteStringValue("data:application/octet-stream;base64," + s);
+ }
+ else
+ {
+ RewriteElement(prop.Value, writer);
+ }
+ }
+ writer.WriteEndObject();
+ break;
+ case JsonValueKind.Array:
+ writer.WriteStartArray();
+ foreach (JsonElement item in element.EnumerateArray())
+ {
+ RewriteElement(item, writer);
+ }
+ writer.WriteEndArray();
+ break;
+ case JsonValueKind.String:
+ writer.WriteStringValue(element.GetString());
+ break;
+ case JsonValueKind.Number:
+ writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
+ break;
+ case JsonValueKind.True:
+ writer.WriteBooleanValue(true);
+ break;
+ case JsonValueKind.False:
+ writer.WriteBooleanValue(false);
+ break;
+ case JsonValueKind.Null:
+ writer.WriteNullValue();
+ break;
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/AzureOpenAIBackend/Properties/launchSettings.json b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/AzureOpenAIBackend/Properties/launchSettings.json
new file mode 100644
index 0000000000..5dbad1ab99
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/AzureOpenAIBackend/Properties/launchSettings.json
@@ -0,0 +1,13 @@
+{
+ "profiles": {
+ "AzureOpenAIBackend": {
+ "commandName": "Project",
+ "launchUrl": "devui",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:50522;http://localhost:50523"
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/AzureOpenAIBackend/README.md b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/AzureOpenAIBackend/README.md
new file mode 100644
index 0000000000..a273c79e32
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/AzureOpenAIBackend/README.md
@@ -0,0 +1,60 @@
+# DevUI File-Search Agent (Azure OpenAI backend)
+
+Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding + Azure OpenAI `file_search` RAG.
+
+This is the **Azure OpenAI Responses** variant. For the Foundry variant, see [the Foundry backend](../FoundryBackend/).
+
+## How It Works
+
+1. **Upload** any supported file (PDF, image, audio, video) via the DevUI chat
+2. **CU analyzes** the file — auto-selects the right analyzer per media type
+3. **Markdown extracted** by CU is uploaded to an Azure OpenAI vector store
+4. **file_search** tool is registered — LLM retrieves top-k relevant chunks
+5. **Ask questions** across all uploaded documents with token-efficient RAG
+
+## Setup
+
+1. Set environment variables:
+
+ ```sh
+ AZURE_OPENAI_ENDPOINT=https://your-aoai-resource.openai.azure.com/
+ AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4.1
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.services.ai.azure.com/
+ ```
+
+2. Log in with Azure CLI (the sample uses `DefaultAzureCredential`):
+
+ ```sh
+ az login
+ ```
+
+3. Run the sample:
+
+ ```sh
+ dotnet run
+ ```
+
+4. Open in a browser and start uploading files.
+
+## Supported File Types
+
+| Type | Formats | CU Analyzer (auto-detected) |
+|------|---------|-----------------------------|
+| Documents | PDF, DOCX, XLSX, PPTX, HTML, TXT, Markdown | `prebuilt-documentSearch` |
+| Images | JPEG, PNG, TIFF, BMP | `prebuilt-documentSearch` |
+| Audio | WAV, MP3, FLAC, OGG, M4A | `prebuilt-audioSearch` |
+| Video | MP4, MOV, AVI, WebM | `prebuilt-videoSearch` |
+
+## vs. the Multi-Modal Agent
+
+| Feature | Multi-Modal Agent | File-Search |
+|---------|---------|-------------------|
+| CU extraction | Full content injected | Content indexed in vector store |
+| RAG | No | `file_search` retrieves top-k chunks |
+| Large docs (100+ pages) | May exceed context window | Token-efficient |
+| Multiple large files | Context overflow risk | All indexed, searchable |
+| Best for | Small docs, quick inspection | Large docs, multi-file Q&A |
+
+## Cleanup
+
+The vector store is created with a 1-day idle expiration policy, so abandoned DevUI sessions are auto-cleaned by Azure OpenAI. The CU provider's `DisposeAsync` (triggered at app shutdown) deletes the per-file uploads it owned; the vector store itself is left to the auto-expiration policy.
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/FoundryBackend/FoundryBackend.csproj b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/FoundryBackend/FoundryBackend.csproj
new file mode 100644
index 0000000000..321773b63a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/FoundryBackend/FoundryBackend.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+
+ $(NoWarn);OPENAI001
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/FoundryBackend/Program.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/FoundryBackend/Program.cs
new file mode 100644
index 0000000000..787682d1ca
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/FoundryBackend/Program.cs
@@ -0,0 +1,288 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// DevUI File-Search Agent (Foundry backend) — CU extraction + file_search RAG via Foundry.
+//
+// This sample hosts a Foundry-backed agent behind the DevUI middleware and
+// wires the Content Understanding provider with the `FileSearchConfig.FromFoundry`
+// backend. Upload large or multi-modal files in the browser; the provider:
+// 1. extracts markdown via CU (handles scanned PDFs, audio, video),
+// 2. uploads the extracted markdown to a Foundry vector store,
+// 3. surfaces the file_search tool on the agent's context for token-efficient RAG.
+//
+// The vector store is auto-expiring (`expires_after = 1 day, last_active_at`) so
+// inactive sample sessions are cleaned up automatically. The CU provider's
+// DisposeAsync deletes the per-file uploads at app shutdown.
+//
+// Environment variables:
+// AZURE_AI_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
+// AZURE_AI_MODEL_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4.1)
+// AZURE_CONTENTUNDERSTANDING_ENDPOINT — Content Understanding endpoint URL
+//
+// Run:
+// dotnet run
+// Then open https://localhost:50524/devui in a browser.
+
+using System.Text;
+using System.Text.Json;
+using Azure.AI.Projects;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.AzureAI.ContentUnderstanding;
+using Microsoft.Agents.AI.DevUI;
+using Microsoft.Agents.AI.Hosting;
+using Microsoft.Extensions.AI;
+using OpenAI.VectorStores;
+
+var builder = WebApplication.CreateBuilder(args);
+
+string projectEndpoint = builder.Configuration["AZURE_AI_PROJECT_ENDPOINT"]
+ ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
+string deploymentName = builder.Configuration["AZURE_AI_MODEL_DEPLOYMENT_NAME"]
+ ?? throw new InvalidOperationException("AZURE_AI_MODEL_DEPLOYMENT_NAME is not set.");
+string cuEndpoint = builder.Configuration["AZURE_CONTENTUNDERSTANDING_ENDPOINT"]
+ ?? throw new InvalidOperationException("AZURE_CONTENTUNDERSTANDING_ENDPOINT is not set.");
+
+// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+var credential = new DefaultAzureCredential();
+var aiProjectClient = new AIProjectClient(new Uri(projectEndpoint), credential);
+
+// 1. Create a Foundry vector store up-front (auto-expires after 1 day idle so abandoned
+// DevUI sessions don't accumulate storage cost). The CU provider uploads each analyzed
+// document into this store; the file_search tool reads from it.
+var projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
+var vectorStoresClient = projectOpenAIClient.GetProjectVectorStoresClient();
+var vectorStoreResult = await vectorStoresClient.CreateVectorStoreAsync(
+ new VectorStoreCreationOptions
+ {
+ Name = "devui_cu_foundry_file_search",
+ ExpirationPolicy = new VectorStoreExpirationPolicy(VectorStoreExpirationAnchor.LastActiveAt, days: 1),
+ });
+string vectorStoreId = vectorStoreResult.Value.Id;
+
+// 2. Build the file_search tool that the agent will use to query the vector store.
+HostedFileSearchTool fileSearchTool = new() { Inputs = [new HostedVectorStoreContent(vectorStoreId)] };
+
+// 3. CU provider with file_search wiring. Singleton — its lifecycle spans the
+// web host. DisposeAsync runs on app shutdown and deletes the files the
+// provider uploaded; the vector store is deleted explicitly below.
+builder.Services.AddSingleton(_ => new ContentUnderstandingContextProvider(
+ new ContentUnderstandingContextProviderOptions(new Uri(cuEndpoint), credential)
+ {
+ // Foreground budget for both CU analysis polling AND vector-store upload polling.
+ // Sample workloads (multi-page PDFs) typically need 10–20 s CU + 5–15 s vector-store
+ // ingestion, so a 60 s budget covers the common case in a single turn. Longer media
+ // (audio/video) that exceeds this budget gets a rehydration token stored on the entry
+ // and resumes on the next turn; the upload then runs in that follow-up turn against a
+ // fresh budget.
+ MaxWait = TimeSpan.FromSeconds(60),
+
+ // DevUI's HostedAgentResponseExecutor creates a fresh AgentSession every
+ // turn, so per-session state would be lost. PerAgent keys state on the
+ // agent instance instead — fine here because each DevUI agent is single-
+ // user. Production multi-tenant hosts MUST keep the default PerSession.
+ StateScope = StateScope.PerAgent,
+
+ FileSearchConfig = FileSearchConfig.FromFoundry(
+ aiProjectClient,
+ vectorStoreId,
+ fileSearchTool),
+ }));
+
+const string AgentName = "FoundryFileSearchDocAgent";
+
+builder.AddAIAgent(AgentName, (sp, key) =>
+{
+ var cu = sp.GetRequiredService();
+ return aiProjectClient.AsAIAgent(new ChatClientAgentOptions
+ {
+ Name = key,
+ ChatOptions = new ChatOptions
+ {
+ ModelId = deploymentName,
+ Instructions = "You are a helpful document analysis assistant with RAG capabilities. "
+ + "When a user uploads files, they are automatically analyzed using Azure Content Understanding "
+ + "and indexed in a vector store for efficient retrieval. "
+ + "Analysis takes time (seconds for documents, longer for audio/video) — if a document "
+ + "is still pending, let the user know and suggest they ask again shortly. "
+ + "You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. "
+ + "Multiple files can be uploaded and queried in the same conversation. "
+ + "When answering, cite specific content from the documents. "
+ + "Format all responses as GitHub-flavored Markdown. When presenting tabular data, "
+ + "use Markdown table syntax (| col1 | col2 |\\n|---|---|\\n| val1 | val2 |) — "
+ + "never emit raw HTML tags like , , or | , since the chat UI does not render HTML.",
+ },
+ AIContextProviders = [cu],
+ });
+});
+
+builder.Services.AddOpenAIResponses();
+builder.Services.AddOpenAIConversations();
+builder.AddDevUI();
+
+var app = builder.Build();
+
+// HACK: Microsoft.Agents.AI.Hosting.OpenAI's ItemContentConverter passes raw base64 from
+// input_file.file_data straight into DataContent(string uri, ...), which requires a
+// "data:" URI and throws ArgumentException otherwise. Until that's fixed upstream,
+// rewrite incoming /v1/responses bodies so raw base64 is wrapped in a data: URI. The
+// Content Understanding provider's MimeSniffer then detects the real media type
+// (PDF / PNG / JPEG / WAV / MP3 / MP4) from the bytes.
+app.Use(static async (ctx, next) =>
+{
+ if (HttpMethods.IsPost(ctx.Request.Method)
+ && ctx.Request.Path.StartsWithSegments("/v1/responses")
+ && (ctx.Request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) ?? false))
+ {
+ ctx.Request.EnableBuffering();
+ string body;
+ using (var reader = new StreamReader(ctx.Request.Body, Encoding.UTF8, leaveOpen: true))
+ {
+ body = await reader.ReadToEndAsync().ConfigureAwait(false);
+ }
+ ctx.Request.Body.Position = 0;
+
+ if (ResponsesRawBase64Workaround.TryRewrite(body, out string rewritten))
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(rewritten);
+ ctx.Request.Body = new MemoryStream(bytes);
+ ctx.Request.ContentLength = bytes.Length;
+ }
+ }
+ await next().ConfigureAwait(false);
+});
+
+app.MapOpenAIResponses();
+app.MapOpenAIConversations();
+
+if (builder.Environment.IsDevelopment())
+{
+ app.MapDevUI();
+}
+
+Console.WriteLine($"DevUI is available at: https://localhost:50524/devui (vector store: {vectorStoreId})");
+Console.WriteLine("OpenAI Responses API is available at: https://localhost:50524/v1/responses");
+Console.WriteLine("Press Ctrl+C to stop the server.");
+
+app.Run();
+
+///
+/// Wraps raw-base64 file_data fields in OpenAI Responses request bodies into data: URIs.
+/// Workaround for Microsoft.Agents.AI.Hosting.OpenAI's ItemContentConverter, which expects
+/// a data: URI form. Drop this once the upstream package handles raw base64 directly.
+///
+internal static class ResponsesRawBase64Workaround
+{
+ public static bool TryRewrite(string body, out string rewritten)
+ {
+ rewritten = body;
+ if (string.IsNullOrEmpty(body))
+ {
+ return false;
+ }
+
+ using JsonDocument doc = JsonDocument.Parse(body);
+ if (!ContainsRawFileData(doc.RootElement))
+ {
+ return false;
+ }
+
+ using MemoryStream stream = new();
+ using (Utf8JsonWriter writer = new(stream))
+ {
+ RewriteElement(doc.RootElement, writer);
+ }
+ rewritten = Encoding.UTF8.GetString(stream.ToArray());
+ return true;
+ }
+
+ private static bool ContainsRawFileData(JsonElement element)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ if (IsInputFile(element)
+ && element.TryGetProperty("file_data", out JsonElement fileData)
+ && fileData.ValueKind == JsonValueKind.String
+ && fileData.GetString() is { Length: > 0 } s
+ && !s.StartsWith("data:", StringComparison.Ordinal))
+ {
+ return true;
+ }
+ foreach (JsonProperty prop in element.EnumerateObject())
+ {
+ if (ContainsRawFileData(prop.Value))
+ {
+ return true;
+ }
+ }
+ return false;
+ case JsonValueKind.Array:
+ foreach (JsonElement item in element.EnumerateArray())
+ {
+ if (ContainsRawFileData(item))
+ {
+ return true;
+ }
+ }
+ return false;
+ default:
+ return false;
+ }
+ }
+
+ private static bool IsInputFile(JsonElement element)
+ => element.TryGetProperty("type", out JsonElement t)
+ && t.ValueKind == JsonValueKind.String
+ && string.Equals(t.GetString(), "input_file", StringComparison.Ordinal);
+
+ private static void RewriteElement(JsonElement element, Utf8JsonWriter writer)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ writer.WriteStartObject();
+ bool inputFile = IsInputFile(element);
+ foreach (JsonProperty prop in element.EnumerateObject())
+ {
+ writer.WritePropertyName(prop.Name);
+ if (inputFile
+ && prop.Name == "file_data"
+ && prop.Value.ValueKind == JsonValueKind.String
+ && prop.Value.GetString() is { Length: > 0 } s
+ && !s.StartsWith("data:", StringComparison.Ordinal))
+ {
+ writer.WriteStringValue("data:application/octet-stream;base64," + s);
+ }
+ else
+ {
+ RewriteElement(prop.Value, writer);
+ }
+ }
+ writer.WriteEndObject();
+ break;
+ case JsonValueKind.Array:
+ writer.WriteStartArray();
+ foreach (JsonElement item in element.EnumerateArray())
+ {
+ RewriteElement(item, writer);
+ }
+ writer.WriteEndArray();
+ break;
+ case JsonValueKind.String:
+ writer.WriteStringValue(element.GetString());
+ break;
+ case JsonValueKind.Number:
+ writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
+ break;
+ case JsonValueKind.True:
+ writer.WriteBooleanValue(true);
+ break;
+ case JsonValueKind.False:
+ writer.WriteBooleanValue(false);
+ break;
+ case JsonValueKind.Null:
+ writer.WriteNullValue();
+ break;
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/FoundryBackend/Properties/launchSettings.json b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/FoundryBackend/Properties/launchSettings.json
new file mode 100644
index 0000000000..94f89e6176
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/FoundryBackend/Properties/launchSettings.json
@@ -0,0 +1,13 @@
+{
+ "profiles": {
+ "FoundryBackend": {
+ "commandName": "Project",
+ "launchUrl": "devui",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:50524;http://localhost:50525"
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/FoundryBackend/README.md b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/FoundryBackend/README.md
new file mode 100644
index 0000000000..8337d0f427
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/02-devui/02_FileSearchAgent/FoundryBackend/README.md
@@ -0,0 +1,60 @@
+# DevUI File-Search Agent (Foundry backend)
+
+Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding + Foundry `file_search` RAG.
+
+This is the **Foundry** variant. For the Azure OpenAI Responses API variant, see [the Azure OpenAI backend](../AzureOpenAIBackend/).
+
+## How It Works
+
+1. **Upload** any supported file (PDF, image, audio, video) via the DevUI chat
+2. **CU analyzes** the file — auto-selects the right analyzer per media type
+3. **Markdown extracted** by CU is uploaded to a Foundry vector store
+4. **file_search** tool is registered — LLM retrieves top-k relevant chunks
+5. **Ask questions** across all uploaded documents with token-efficient RAG
+
+## Setup
+
+1. Set environment variables:
+
+ ```sh
+ AZURE_AI_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/
+ AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4.1
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.services.ai.azure.com/
+ ```
+
+2. Log in with Azure CLI (the sample uses `DefaultAzureCredential`):
+
+ ```sh
+ az login
+ ```
+
+3. Run the sample:
+
+ ```sh
+ dotnet run
+ ```
+
+4. Open in a browser and start uploading files.
+
+## Supported File Types
+
+| Type | Formats | CU Analyzer (auto-detected) |
+|------|---------|-----------------------------|
+| Documents | PDF, DOCX, XLSX, PPTX, HTML, TXT, Markdown | `prebuilt-documentSearch` |
+| Images | JPEG, PNG, TIFF, BMP | `prebuilt-documentSearch` |
+| Audio | WAV, MP3, FLAC, OGG, M4A | `prebuilt-audioSearch` |
+| Video | MP4, MOV, AVI, WebM | `prebuilt-videoSearch` |
+
+## vs. the Multi-Modal Agent
+
+| Feature | Multi-Modal Agent | File-Search |
+|---------|---------|-------------------|
+| CU extraction | Full content injected | Content indexed in vector store |
+| RAG | No | `file_search` retrieves top-k chunks |
+| Large docs (100+ pages) | May exceed context window | Token-efficient |
+| Multiple large files | Context overflow risk | All indexed, searchable |
+| Best for | Small docs, quick inspection | Large docs, multi-file Q&A |
+
+## Cleanup
+
+The Foundry vector store is created with a 1-day idle expiration policy, so abandoned DevUI sessions are auto-cleaned. The CU provider's `DisposeAsync` (triggered at app shutdown) deletes the per-file uploads it owned; the vector store itself is left to the auto-expiration policy.
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/Directory.Build.props b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/Directory.Build.props
new file mode 100644
index 0000000000..dc96ace7c4
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/Directory.Build.props
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+ false
+ false
+ 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0
+ $(NoWarn);MAAI001
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/README.md b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/README.md
new file mode 100644
index 0000000000..82e12e0d08
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/README.md
@@ -0,0 +1,55 @@
+# Agent With Content Understanding
+
+These samples demonstrate the [Azure Content Understanding context provider](..) for `Microsoft.Agents.AI`. Each sample wires the provider into a Foundry- or Azure-OpenAI-backed agent so the agent can answer questions about uploaded documents, audio, and video using Azure Content Understanding for extraction.
+
+These samples live under the package directory and mirror the layout of the [`agent-framework-azure-contentunderstanding` Python package samples](https://github.com/microsoft/agent-framework/tree/main/python/packages/azure-contentunderstanding/samples):
+
+- **[`01-get-started/`](01-get-started/)** — script-style flows (easy → advanced).
+- **[`02-devui/`](02-devui/)** — the provider hosted behind the [DevUI](../../Microsoft.Agents.AI.DevUI) web interface.
+
+## Prerequisites
+
+| Environment variable | Used by | Description |
+| --- | --- | --- |
+| `AZURE_AI_PROJECT_ENDPOINT` | 01-get-started, DevUI multimodal & Foundry backend | Azure AI Foundry project endpoint URL. |
+| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | 01-get-started, DevUI multimodal & Foundry backend | Foundry model deployment name (defaults to `gpt-4.1`). |
+| `AZURE_OPENAI_ENDPOINT` | DevUI Azure OpenAI backend | Azure OpenAI endpoint URL. |
+| `AZURE_OPENAI_DEPLOYMENT_NAME` | DevUI Azure OpenAI backend | Azure OpenAI chat-model deployment name (defaults to `gpt-4.1`). |
+| `AZURE_CONTENTUNDERSTANDING_ENDPOINT` | All samples | Azure Content Understanding endpoint URL. |
+
+All samples authenticate with `DefaultAzureCredential` (e.g. `az login` for local dev).
+
+The script samples copy `shared/SampleAssets/invoice.pdf` to the project output directory at build time. The multi-modal chat script (`03_MultimodalChat`) also loads audio / video over HTTPS from the public [Azure Content Understanding sample assets repo](https://github.com/Azure-Samples/azure-ai-content-understanding-assets).
+
+## Running a sample
+
+```sh
+cd dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/01-get-started/01_DocumentQA
+dotnet run
+```
+
+The DevUI samples launch an ASP.NET Core server; once running, open the URL printed in the console (typically `https://localhost:5052x/devui`).
+
+### 01-get-started — script samples
+
+| # | Sample | Description |
+| --- | --- | --- |
+| 01 | [01_DocumentQA](01-get-started/01_DocumentQA/Program.cs) | Single-turn PDF Q&A. |
+| 02 | [02_MultiTurnSession](01-get-started/02_MultiTurnSession/Program.cs) | 3-turn session with cached CU results. |
+| 03 | [03_MultimodalChat](01-get-started/03_MultimodalChat/Program.cs) | PDF + audio URL + video URL analyzed in parallel; 5-turn session. |
+| 04 | [04_InvoiceProcessing](01-get-started/04_InvoiceProcessing/Program.cs) | `prebuilt-invoice` analyzer with fields-only output. |
+| 05 | [05_LargeDocFileSearch](01-get-started/05_LargeDocFileSearch/Program.cs) | `FileSearchConfig.FromFoundry` — CU markdown auto-uploaded to a vector store; agent queries via the `file_search` tool. |
+
+### 02-devui — interactive web UI samples
+
+| # | Sample | Description |
+| --- | --- | --- |
+| 01 | [01_MultimodalAgent](02-devui/01_MultimodalAgent/Program.cs) | Foundry-backed multimodal agent hosted in the DevUI web interface. |
+| 02a | [02_FileSearchAgent/AzureOpenAIBackend](02-devui/02_FileSearchAgent/AzureOpenAIBackend/Program.cs) | Azure-OpenAI–backed file_search RAG hosted in DevUI; `FileSearchConfig.FromOpenAI`. |
+| 02b | [02_FileSearchAgent/FoundryBackend](02-devui/02_FileSearchAgent/FoundryBackend/Program.cs) | Foundry-backed file_search RAG hosted in DevUI; `FileSearchConfig.FromFoundry`. |
+
+## Notes
+
+- **Per-attachment analyzer override** (`04_InvoiceProcessing`): the provider currently exposes only a global `ContentUnderstandingContextProviderOptions.AnalyzerId`. Mixing analyzers (for example `prebuilt-documentSearch` and `prebuilt-invoice`) within a single message is not yet supported. For sample 04, which uses a single attachment, the global setting is equivalent. Tracking the mixed-analyzer case as a follow-up.
+- **`OPENAI001` suppression** (`05_LargeDocFileSearch`, `02_FileSearchAgent/AzureOpenAIBackend`, `02_FileSearchAgent/FoundryBackend`): the Foundry / OpenAI vector-store APIs in `OpenAI 2.10` are tagged `[Experimental("OPENAI001")]`. The vector-store samples add `$(NoWarn);OPENAI001` to their `.csproj` for that reason. The `Microsoft.Agents.AI.AzureAI.ContentUnderstanding` library itself never leaks the warning to consumers.
+- **Cleanup boundaries**: the CU provider's `DisposeAsync` deletes any files it uploaded into a vector store (so `file_search` indexing artifacts don't accumulate). The vector store itself stays under caller ownership — the script sample `05_LargeDocFileSearch` and the Foundry DevUI sample `02_FileSearchAgent/FoundryBackend` delete it explicitly; the Azure-OpenAI DevUI sample `02_FileSearchAgent/AzureOpenAIBackend` relies on the vector store's 1-day idle expiration policy.
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/shared/SampleAssets/invoice.pdf b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/shared/SampleAssets/invoice.pdf
new file mode 100644
index 0000000000..812bcd9b30
Binary files /dev/null and b/dotnet/src/Microsoft.Agents.AI.AzureAI.ContentUnderstanding/samples/shared/SampleAssets/invoice.pdf differ
diff --git a/dotnet/tests/AzureAIContentUnderstanding.IntegrationTests/AzureAIContentUnderstanding.IntegrationTests.csproj b/dotnet/tests/AzureAIContentUnderstanding.IntegrationTests/AzureAIContentUnderstanding.IntegrationTests.csproj
new file mode 100644
index 0000000000..7ea4d9c03c
--- /dev/null
+++ b/dotnet/tests/AzureAIContentUnderstanding.IntegrationTests/AzureAIContentUnderstanding.IntegrationTests.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ $(NoWarn);CS8793
+ True
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/tests/AzureAIContentUnderstanding.IntegrationTests/ContentUnderstandingLiveTests.cs b/dotnet/tests/AzureAIContentUnderstanding.IntegrationTests/ContentUnderstandingLiveTests.cs
new file mode 100644
index 0000000000..73b6ad385a
--- /dev/null
+++ b/dotnet/tests/AzureAIContentUnderstanding.IntegrationTests/ContentUnderstandingLiveTests.cs
@@ -0,0 +1,199 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Azure.AI.Projects;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.AzureAI.ContentUnderstanding;
+using Microsoft.Extensions.AI;
+
+namespace AzureAIContentUnderstanding.IntegrationTests;
+
+///
+/// Live integration tests for .
+/// Each test is gated on the environment variables listed in its Skip check.
+/// When run in CI without credentials, every test skips cleanly.
+///
+/// Required environment variables:
+/// AZURE_AI_PROJECT_ENDPOINT, AZURE_AI_MODEL_DEPLOYMENT_NAME,
+/// AZURE_CONTENTUNDERSTANDING_ENDPOINT.
+///
+[Trait("Category", "Live")]
+public sealed class ContentUnderstandingLiveTests
+{
+ private const string ProjectEndpointVar = "AZURE_AI_PROJECT_ENDPOINT";
+ private const string ModelDeploymentVar = "AZURE_AI_MODEL_DEPLOYMENT_NAME";
+ private const string CuEndpointVar = "AZURE_CONTENTUNDERSTANDING_ENDPOINT";
+
+ private static string SampleAssetsRoot => Path.Combine(
+ AppContext.BaseDirectory,
+ "..", "..", "..", "..", "..",
+ "samples", "02-agents", "AgentWithContentUnderstanding", "SampleAssets");
+
+ [Fact]
+ public async Task PdfQa_InvoiceDocument_ReturnsVendorAndTotalAsync()
+ {
+ (string projectEndpoint, string modelDeployment, string cuEndpoint) = RequireLiveEnvironmentOrSkip();
+ string invoicePath = Path.Combine(SampleAssetsRoot, "invoice.pdf");
+ Assert.SkipUnless(File.Exists(invoicePath), $"Sample asset not found at {invoicePath}.");
+
+ var credential = new DefaultAzureCredential();
+ await using var cu = new ContentUnderstandingContextProvider(
+ new ContentUnderstandingContextProviderOptions(new Uri(cuEndpoint), credential)
+ {
+ AnalyzerId = "prebuilt-documentSearch",
+ MaxWait = TimeSpan.FromMinutes(2),
+ });
+
+ AIProjectClient projectClient = new(new Uri(projectEndpoint), credential);
+ AIAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions
+ {
+ Name = "DocumentQA",
+ ChatOptions = new ChatOptions
+ {
+ ModelId = modelDeployment,
+ Instructions =
+ "You are a helpful document analyst. Use the analyzed document content "
+ + "and extracted fields to answer precisely.",
+ },
+ AIContextProviders = [cu],
+ });
+
+ byte[] pdfBytes = await File.ReadAllBytesAsync(invoicePath);
+ DataContent pdf = new(pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ ChatMessage userMessage = new(
+ ChatRole.User,
+ [
+ new TextContent("Who is the vendor and what is the total amount due?"),
+ pdf,
+ ]);
+
+ AgentResponse response = await agent.RunAsync(userMessage);
+
+ Assert.NotNull(response);
+ string text = response.ToString();
+ Assert.False(string.IsNullOrWhiteSpace(text), "Agent returned an empty response.");
+ }
+
+ [Fact]
+ public async Task InvoiceFieldExtraction_PrebuiltInvoiceAnalyzer_FieldsFlowIntoContextAsync()
+ {
+ (string projectEndpoint, string modelDeployment, string cuEndpoint) = RequireLiveEnvironmentOrSkip();
+ string invoicePath = Path.Combine(SampleAssetsRoot, "invoice.pdf");
+ Assert.SkipUnless(File.Exists(invoicePath), $"Sample asset not found at {invoicePath}.");
+
+ var credential = new DefaultAzureCredential();
+ await using var cu = new ContentUnderstandingContextProvider(
+ new ContentUnderstandingContextProviderOptions(new Uri(cuEndpoint), credential)
+ {
+ AnalyzerId = "prebuilt-invoice",
+ MaxWait = TimeSpan.FromMinutes(2),
+ });
+
+ AIProjectClient projectClient = new(new Uri(projectEndpoint), credential);
+ AIAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions
+ {
+ Name = "InvoiceAnalyst",
+ ChatOptions = new ChatOptions
+ {
+ ModelId = modelDeployment,
+ Instructions =
+ "Use the extracted invoice fields (vendor name, total amount) to answer.",
+ },
+ AIContextProviders = [cu],
+ });
+
+ byte[] pdfBytes = await File.ReadAllBytesAsync(invoicePath);
+ DataContent pdf = new(pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ ChatMessage userMessage = new(
+ ChatRole.User,
+ [
+ new TextContent("List the vendor name and the total invoice amount exactly as printed."),
+ pdf,
+ ]);
+
+ AgentResponse response = await agent.RunAsync(userMessage);
+
+ Assert.NotNull(response);
+ Assert.False(string.IsNullOrWhiteSpace(response.ToString()));
+ }
+
+ [Fact]
+ public async Task MultiTurnSession_SecondTurn_ReusesPreviousAnalysisWithoutReanalyzingAsync()
+ {
+ (string projectEndpoint, string modelDeployment, string cuEndpoint) = RequireLiveEnvironmentOrSkip();
+ string invoicePath = Path.Combine(SampleAssetsRoot, "invoice.pdf");
+ Assert.SkipUnless(File.Exists(invoicePath), $"Sample asset not found at {invoicePath}.");
+
+ var credential = new DefaultAzureCredential();
+ await using var cu = new ContentUnderstandingContextProvider(
+ new ContentUnderstandingContextProviderOptions(new Uri(cuEndpoint), credential)
+ {
+ AnalyzerId = "prebuilt-documentSearch",
+ MaxWait = TimeSpan.FromMinutes(2),
+ });
+
+ AIProjectClient projectClient = new(new Uri(projectEndpoint), credential);
+ AIAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions
+ {
+ Name = "DocumentChat",
+ ChatOptions = new ChatOptions
+ {
+ ModelId = modelDeployment,
+ Instructions = "Answer based on the previously analyzed document.",
+ },
+ AIContextProviders = [cu],
+ });
+
+ AgentSession session = await agent.CreateSessionAsync();
+
+ byte[] pdfBytes = await File.ReadAllBytesAsync(invoicePath);
+ DataContent pdf = new(pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+
+ ChatMessage turn1 = new(ChatRole.User, [new TextContent("Summarize this document."), pdf]);
+ AgentResponse response1 = await agent.RunAsync(turn1, session);
+ Assert.NotNull(response1);
+
+ // Turn 2: text-only follow-up — should not re-analyze, just reuse cached context.
+ ChatMessage turn2 = new(ChatRole.User, [new TextContent("What was the total amount?")]);
+ AgentResponse response2 = await agent.RunAsync(turn2, session);
+ Assert.NotNull(response2);
+ Assert.False(string.IsNullOrWhiteSpace(response2.ToString()));
+ }
+
+ [Fact]
+ public async Task Dispose_CompletesWithoutHangingBackgroundTasksAsync()
+ {
+ (_, _, string cuEndpoint) = RequireLiveEnvironmentOrSkip();
+
+ var credential = new DefaultAzureCredential();
+ var cu = new ContentUnderstandingContextProvider(
+ new ContentUnderstandingContextProviderOptions(new Uri(cuEndpoint), credential)
+ {
+ AnalyzerId = "prebuilt-documentSearch",
+ MaxWait = TimeSpan.FromMilliseconds(1), // force background path
+ });
+
+ // Disposing immediately, before any analysis is scheduled, must complete promptly.
+ var disposeTask = cu.DisposeAsync().AsTask();
+ var winner = await Task.WhenAny(disposeTask, Task.Delay(TimeSpan.FromSeconds(10)));
+ Assert.Same(disposeTask, winner);
+ }
+
+ private static (string ProjectEndpoint, string ModelDeployment, string CuEndpoint) RequireLiveEnvironmentOrSkip()
+ {
+ string? project = Environment.GetEnvironmentVariable(ProjectEndpointVar);
+ string? model = Environment.GetEnvironmentVariable(ModelDeploymentVar);
+ string? cu = Environment.GetEnvironmentVariable(CuEndpointVar);
+
+ if (string.IsNullOrWhiteSpace(project) || string.IsNullOrWhiteSpace(model) || string.IsNullOrWhiteSpace(cu))
+ {
+ Assert.Skip(
+ $"Live test requires {ProjectEndpointVar}, {ModelDeploymentVar}, {CuEndpointVar} environment variables.");
+ }
+
+ return (project!, model!, cu!);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/AnalysisRendererSegmentsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/AnalysisRendererSegmentsTests.cs
new file mode 100644
index 0000000000..af6fe3d276
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/AnalysisRendererSegmentsTests.cs
@@ -0,0 +1,113 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.AI.ContentUnderstanding;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 8 — multi-segment audio/video coverage. The CU SDK's
+/// already concatenates per-segment blocks; these tests pin that behavior end-to-end through
+/// our renderer wrapper and the provider's injection path so an upstream regression cannot
+/// silently break the multi-segment story without a failing test.
+///
+public sealed class AnalysisRendererSegmentsTests
+{
+ [Fact]
+ public void Render_MultiSegmentVideo_EmitsTimeRangePerSegment_WithSeparators()
+ {
+ AnalysisResult result = SharedTestFixtures.MakeMultiSegmentVideoResult(segmentCount: 3, segmentDurationSec: 30);
+
+ string rendered = AnalysisRenderer.Render(result, "demo.mp4", AnalysisSection.Markdown);
+
+ // Three audioVisual blocks → three timeRange front-matter entries.
+ int timeRangeCount = CountOccurrences(rendered, "timeRange:");
+ Assert.Equal(3, timeRangeCount);
+
+ // LlmInputHelper joins blocks with "\n\n*****\n\n" — verify two separators between three blocks.
+ int separatorCount = CountOccurrences(rendered, "*****");
+ Assert.Equal(2, separatorCount);
+
+ // Each segment's markdown is present.
+ Assert.Contains("## Segment 0", rendered, StringComparison.Ordinal);
+ Assert.Contains("## Segment 1", rendered, StringComparison.Ordinal);
+ Assert.Contains("## Segment 2", rendered, StringComparison.Ordinal);
+
+ // Front-matter source repeats per block (one per segment).
+ Assert.Equal(3, CountOccurrences(rendered, "source: demo.mp4"));
+ Assert.Equal(3, CountOccurrences(rendered, "contentType: audioVisual"));
+ }
+
+ [Fact]
+ public void Render_SingleSegmentVideo_OmitsTimeRangeAndSeparators()
+ {
+ AnalysisResult result = SharedTestFixtures.MakeMultiSegmentVideoResult(segmentCount: 1, segmentDurationSec: 30);
+
+ string rendered = AnalysisRenderer.Render(result, "short.mp4", AnalysisSection.Markdown);
+
+ // Per LlmInputHelper, timeRange is only emitted when multiple AV contents are present.
+ Assert.DoesNotContain("timeRange:", rendered, StringComparison.Ordinal);
+ Assert.DoesNotContain("*****", rendered, StringComparison.Ordinal);
+ Assert.Contains("## Segment 0", rendered, StringComparison.Ordinal);
+ Assert.Contains("contentType: audioVisual", rendered, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_MultiSegmentVideo_InjectsAllSegmentsIntoMessages()
+ {
+ AnalysisResult videoResult = SharedTestFixtures.MakeMultiSegmentVideoResult(segmentCount: 3, segmentDurationSec: 30);
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "demo.mp4",
+ new AnalysisOutcome(true, videoResult, "op-1", null, TimeSpan.FromMilliseconds(50)));
+
+ await using ContentUnderstandingContextProvider provider = new(
+ SharedTestFixtures.TestEndpoint,
+ new FakeTokenCredential())
+ {
+ ClientFactoryOverride = new CountingClientFactory(),
+ AnalyzeOverride = analyzer.AnalyzeAsync,
+ };
+
+ // Real video bytes aren't needed — DataContent.MediaType is honored when supplied.
+ DataContent video = new(new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70 }, "video/mp4")
+ {
+ Name = "demo.mp4",
+ };
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(),
+ new AgentSessionFake(),
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Summarize."), video]) } }),
+ CancellationToken.None);
+
+ Assert.Equal(1, analyzer.CallCount);
+ Assert.Equal("prebuilt-videoSearch", analyzer.Calls[0].AnalyzerId);
+
+ List messages = result.Messages!.ToList();
+ ChatMessage systemNote = messages.First(m => m.Role == ChatRole.System);
+ string injected = string.Concat(systemNote.Contents.OfType().Select(t => t.Text));
+
+ // All three segments reach the agent context in one block.
+ Assert.Contains("## Segment 0", injected, StringComparison.Ordinal);
+ Assert.Contains("## Segment 1", injected, StringComparison.Ordinal);
+ Assert.Contains("## Segment 2", injected, StringComparison.Ordinal);
+ Assert.Equal(3, CountOccurrences(injected, "timeRange:"));
+ }
+
+ private static int CountOccurrences(string haystack, string needle)
+ {
+ int count = 0;
+ int index = 0;
+ while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ index += needle.Length;
+ }
+ return count;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/AnalysisRendererTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/AnalysisRendererTests.cs
new file mode 100644
index 0000000000..9ea853fc56
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/AnalysisRendererTests.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using Azure.AI.ContentUnderstanding;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 4 / dev plan tasks 4.1 + 4.2 — wraps
+/// and strips spurious telemetry lines.
+///
+public sealed class AnalysisRendererTests
+{
+ private static AnalysisResult MakeInvoiceResult()
+ {
+ Dictionary fields = new(StringComparer.Ordinal)
+ {
+ ["VendorName"] = ContentUnderstandingModelFactory.ContentStringField(value: "CONTOSO LTD."),
+ ["InvoiceDate"] = ContentUnderstandingModelFactory.ContentStringField(value: "2019-11-15"),
+ };
+
+ DocumentContent content = ContentUnderstandingModelFactory.DocumentContent(
+ mimeType: "application/pdf",
+ markdown: "CONTOSO LTD.\n\n# INVOICE\nSome body text.",
+ fields: fields,
+ startPageNumber: 1,
+ endPageNumber: 1);
+
+ return ContentUnderstandingModelFactory.AnalysisResult(contents: [content]);
+ }
+
+ [Fact]
+ public void Render_WithMarkdownAndFields_ContainsBothSections()
+ {
+ AnalysisResult result = MakeInvoiceResult();
+
+ string rendered = AnalysisRenderer.Render(result, "invoice.pdf", AnalysisSection.Markdown | AnalysisSection.Fields);
+
+ Assert.Contains("source: invoice.pdf", rendered, StringComparison.Ordinal);
+ Assert.Contains("fields:", rendered, StringComparison.Ordinal);
+ Assert.Contains("VendorName", rendered, StringComparison.Ordinal);
+ Assert.Contains("CONTOSO LTD.", rendered, StringComparison.Ordinal);
+ Assert.Contains("# INVOICE", rendered, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Render_MarkdownOnly_OmitsFieldsBlock()
+ {
+ AnalysisResult result = MakeInvoiceResult();
+
+ string rendered = AnalysisRenderer.Render(result, "invoice.pdf", AnalysisSection.Markdown);
+
+ Assert.Contains("# INVOICE", rendered, StringComparison.Ordinal);
+ Assert.DoesNotContain("fields:", rendered, StringComparison.Ordinal);
+ Assert.DoesNotContain("VendorName", rendered, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Render_FieldsOnly_OmitsMarkdownBody()
+ {
+ AnalysisResult result = MakeInvoiceResult();
+
+ string rendered = AnalysisRenderer.Render(result, "invoice.pdf", AnalysisSection.Fields);
+
+ Assert.Contains("VendorName", rendered, StringComparison.Ordinal);
+ Assert.DoesNotContain("# INVOICE", rendered, StringComparison.Ordinal);
+ Assert.DoesNotContain("Some body text.", rendered, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Render_EmptyContents_ReturnsEmptyString()
+ {
+ AnalysisResult empty = ContentUnderstandingModelFactory.AnalysisResult(contents: []);
+
+ string rendered = AnalysisRenderer.Render(empty, "invoice.pdf", AnalysisSection.Default);
+
+ Assert.Equal(string.Empty, rendered);
+ }
+
+ [Fact]
+ public void Render_NullResult_Throws()
+ => Assert.Throws(() => AnalysisRenderer.Render(null!, "x.pdf", AnalysisSection.Default));
+
+ [Fact]
+ public void Render_EmptyFilename_Throws()
+ {
+ AnalysisResult result = MakeInvoiceResult();
+ Assert.Throws(() => AnalysisRenderer.Render(result, string.Empty, AnalysisSection.Default));
+ }
+
+ [Fact]
+ public void LlmInputHelper_AssemblyVersionMajorMinor_Matches1Dot2()
+ {
+ Version? v = typeof(LlmInputHelper).Assembly.GetName().Version;
+ Assert.NotNull(v);
+ Assert.Equal(1, v!.Major);
+ Assert.Equal(2, v.Minor);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/AnalyzerSelectorTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/AnalyzerSelectorTests.cs
new file mode 100644
index 0000000000..193c215588
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/AnalyzerSelectorTests.cs
@@ -0,0 +1,35 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 3 / dev plan task 3.3 — media type → analyzer mapping.
+///
+public sealed class AnalyzerSelectorTests
+{
+ [Theory]
+ [InlineData("application/pdf", "prebuilt-documentSearch")]
+ [InlineData("image/png", "prebuilt-documentSearch")]
+ [InlineData("image/jpeg", "prebuilt-documentSearch")]
+ [InlineData("audio/mpeg", "prebuilt-audioSearch")]
+ [InlineData("audio/wav", "prebuilt-audioSearch")]
+ [InlineData("AUDIO/MPEG", "prebuilt-audioSearch")] // case insensitive
+ [InlineData("video/mp4", "prebuilt-videoSearch")]
+ [InlineData("Video/MP4", "prebuilt-videoSearch")]
+ [InlineData("text/plain", "prebuilt-documentSearch")]
+ [InlineData("", "prebuilt-documentSearch")]
+ public void Select_BucketsByMediaType(string mediaType, string expected)
+ => Assert.Equal(expected, AnalyzerSelector.Select(mediaType, explicitOverride: null));
+
+ [Fact]
+ public void Select_ExplicitOverrideWinsOverAuto()
+ => Assert.Equal("my-custom-analyzer", AnalyzerSelector.Select("audio/mpeg", "my-custom-analyzer"));
+
+ [Fact]
+ public void Select_EmptyOverrideFallsThroughToAuto()
+ => Assert.Equal(AnalyzerSelector.AudioAnalyzer, AnalyzerSelector.Select("audio/mpeg", string.Empty));
+
+ [Fact]
+ public void Select_WhitespaceOverrideFallsThroughToAuto()
+ => Assert.Equal(AnalyzerSelector.AudioAnalyzer, AnalyzerSelector.Select("audio/mpeg", " "));
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/AttachmentDetectorTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/AttachmentDetectorTests.cs
new file mode 100644
index 0000000000..86bef989aa
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/AttachmentDetectorTests.cs
@@ -0,0 +1,243 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Linq;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 3 / dev plan task 3.2 — AttachmentDetector walks ChatMessage.Contents and resolves
+/// media type + filename for each / .
+///
+public sealed class AttachmentDetectorTests
+{
+ private static readonly byte[] s_pdfBytes =
+ [
+ 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37, 0x0A, 0x25, 0xE2, 0xE3, 0xCF, 0xD3,
+ ];
+
+ private static readonly byte[] s_pngBytes =
+ [
+ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00,
+ ];
+
+ [Fact]
+ public void YieldsEmpty_ForMessagesWithoutSupportedContent()
+ {
+ ChatMessage msg = new(ChatRole.User, [new TextContent("hello")]);
+ Assert.Empty(AttachmentDetector.Detect([msg]));
+ }
+
+ [Fact]
+ public void YieldsEmpty_ForEmptyMessages()
+ => Assert.Empty(AttachmentDetector.Detect([]));
+
+ [Fact]
+ public void DetectsDataContent_WithExplicitMediaType()
+ {
+ DataContent dc = new(s_pdfBytes, "application/pdf") { Name = "contract.pdf" };
+ ChatMessage msg = new(ChatRole.User, [new TextContent("Read this"), dc]);
+
+ DetectedAttachment[] detected = AttachmentDetector.Detect([msg]).ToArray();
+
+ Assert.Single(detected);
+ Assert.Equal("application/pdf", detected[0].ResolvedMediaType);
+ Assert.Equal("contract.pdf", detected[0].Filename);
+ Assert.Same(dc, detected[0].OriginalContent);
+ Assert.NotNull(detected[0].Data);
+ Assert.Null(detected[0].Uri);
+ }
+
+ [Fact]
+ public void DetectsDataContent_FillsFilenameFromAdditionalProperties_WhenNameMissing()
+ {
+ DataContent dc = new(s_pdfBytes, "application/pdf")
+ {
+ AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = "from-props.pdf" },
+ };
+ ChatMessage msg = new(ChatRole.User, [dc]);
+
+ DetectedAttachment one = Assert.Single(AttachmentDetector.Detect([msg]));
+ Assert.Equal("from-props.pdf", one.Filename);
+ }
+
+ [Fact]
+ public void DetectsDataContent_SynthesizesFilename_WhenNeitherSourcePresent()
+ {
+ DataContent dc = new(s_pdfBytes, "application/pdf");
+ ChatMessage msg = new(ChatRole.User, [dc]);
+
+ DetectedAttachment one = Assert.Single(AttachmentDetector.Detect([msg]));
+
+ Assert.StartsWith("attachment-", one.Filename);
+ Assert.EndsWith(".pdf", one.Filename);
+ // 12 hex chars (6 bytes) between "attachment-" and ".pdf"
+ Assert.Matches("^attachment-[0-9a-f]{12}\\.pdf$", one.Filename);
+ }
+
+ [Fact]
+ public void DetectsDataContent_ResniffsWhenOctetStream()
+ {
+ // Caller incorrectly tagged a PNG as octet-stream; sniffer must override.
+ DataContent dc = new(s_pngBytes, "application/octet-stream") { Name = "icon.png" };
+ ChatMessage msg = new(ChatRole.User, [dc]);
+
+ DetectedAttachment one = Assert.Single(AttachmentDetector.Detect([msg]));
+ Assert.Equal("image/png", one.ResolvedMediaType);
+ }
+
+ [Fact]
+ public void SilentlySkips_OctetStreamWithUnknownBytes()
+ {
+ DataContent dc = new(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }, "application/octet-stream") { Name = "blob.bin" };
+ ChatMessage msg = new(ChatRole.User, [dc]);
+
+ Assert.Empty(AttachmentDetector.Detect([msg]));
+ }
+
+ [Fact]
+ public void SilentlySkips_UnsupportedMediaType()
+ {
+ // application/zip is not in SUPPORTED_MEDIA_TYPES — must skip.
+ DataContent dc = new(new byte[] { 0x50, 0x4B, 0x03, 0x04 }, "application/zip") { Name = "bundle.zip" };
+ ChatMessage msg = new(ChatRole.User, [dc]);
+
+ Assert.Empty(AttachmentDetector.Detect([msg]));
+ }
+
+ [Fact]
+ public void SilentlySkips_UriContentWithUnsupportedMediaType()
+ {
+ UriContent uc = new("https://example.com/data.json", "application/json");
+ ChatMessage msg = new(ChatRole.User, [uc]);
+
+ Assert.Empty(AttachmentDetector.Detect([msg]));
+ }
+
+ [Fact]
+ public void DetectsUriContent_WithFilenameFromUriPath()
+ {
+ UriContent uc = new("https://contoso.blob.core.windows.net/files/audio/callcenter.mp3", "audio/mpeg");
+ ChatMessage msg = new(ChatRole.User, [uc]);
+
+ DetectedAttachment one = Assert.Single(AttachmentDetector.Detect([msg]));
+ Assert.Equal("audio/mpeg", one.ResolvedMediaType);
+ Assert.Equal("callcenter.mp3", one.Filename);
+ Assert.Null(one.Data);
+ Assert.NotNull(one.Uri);
+ }
+
+ [Fact]
+ public void DetectsUriContent_PrefersAdditionalPropertiesFilenameOverUriPath()
+ {
+ UriContent uc = new("https://contoso.blob.core.windows.net/files/something.dat", "audio/mpeg")
+ {
+ AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = "from-props.mp3" },
+ };
+ ChatMessage msg = new(ChatRole.User, [uc]);
+
+ DetectedAttachment one = Assert.Single(AttachmentDetector.Detect([msg]));
+ Assert.Equal("from-props.mp3", one.Filename);
+ }
+
+ [Fact]
+ public void DetectsUriContent_SynthesizesFilename_WhenUriHasNoExtension()
+ {
+ UriContent uc = new("https://contoso.blob.core.windows.net/api/stream", "video/mp4");
+ ChatMessage msg = new(ChatRole.User, [uc]);
+
+ DetectedAttachment one = Assert.Single(AttachmentDetector.Detect([msg]));
+ Assert.Matches("^attachment-[0-9a-f]{12}\\.mp4$", one.Filename);
+ }
+
+ [Fact]
+ public void DetectsMultipleAttachments_AcrossMessages()
+ {
+ ChatMessage msg1 = new(ChatRole.User,
+ [
+ new TextContent("First"),
+ new DataContent(s_pdfBytes, "application/pdf") { Name = "first.pdf" },
+ ]);
+ ChatMessage msg2 = new(ChatRole.User,
+ [
+ new UriContent("https://example.com/movie.mp4", "video/mp4"),
+ ]);
+
+ DetectedAttachment[] detected = AttachmentDetector.Detect([msg1, msg2]).ToArray();
+
+ Assert.Equal(2, detected.Length);
+ Assert.Equal("first.pdf", detected[0].Filename);
+ Assert.Equal("movie.mp4", detected[1].Filename);
+ }
+
+ [Fact]
+ public void ResolvedMediaType_FallsBackToSuppliedWhenSniffFails()
+ {
+ // Caller knows it's PDF; bytes don't (yet) carry the magic — supplied wins.
+ DataContent dc = new(new byte[] { 0x01, 0x02 }, "application/pdf") { Name = "x.pdf" };
+ ChatMessage msg = new(ChatRole.User, [dc]);
+
+ DetectedAttachment one = Assert.Single(AttachmentDetector.Detect([msg]));
+ Assert.Equal("application/pdf", one.ResolvedMediaType);
+ }
+
+ [Fact]
+ // Filename is interpolated into LLM-visible markdown (AnalysisRenderer YAML front-matter "source:"
+ // and per-document "indexed in vector store" notes), so control chars / newlines must be neutralized.
+ public void DetectsDataContent_StripsControlCharsFromFilename()
+ {
+ DataContent dc = new(s_pdfBytes, "application/pdf")
+ {
+ Name = "report\nignore-previous.pdf\x01",
+ };
+ ChatMessage msg = new(ChatRole.User, [dc]);
+
+ DetectedAttachment one = Assert.Single(AttachmentDetector.Detect([msg]));
+ Assert.DoesNotContain('\n', one.Filename);
+ Assert.DoesNotContain('\r', one.Filename);
+ Assert.DoesNotContain('\t', one.Filename);
+ Assert.DoesNotContain('\x01', one.Filename);
+ Assert.Equal("report ignore-previous.pdf", one.Filename);
+ }
+
+ [Fact]
+ // security: path-traversal hardening — slash / backslash separators and ".." segments are removed.
+ public void DetectsDataContent_StripsPathSeparatorsAndDotDot()
+ {
+ DataContent dc = new(s_pdfBytes, "application/pdf")
+ {
+ Name = "../../etc/passwd.pdf",
+ };
+ ChatMessage msg = new(ChatRole.User, [dc]);
+
+ DetectedAttachment one = Assert.Single(AttachmentDetector.Detect([msg]));
+ Assert.DoesNotContain('/', one.Filename);
+ Assert.DoesNotContain('\\', one.Filename);
+ Assert.DoesNotContain("..", one.Filename);
+ Assert.Equal("etc passwd.pdf", one.Filename);
+ }
+
+ [Fact]
+ // security: cap filename length at 255 chars so a hostile caller can't pad context with a huge name.
+ public void DetectsDataContent_CapsFilenameAt255Characters()
+ {
+ string huge = new string('a', 1000) + ".pdf";
+ DataContent dc = new(s_pdfBytes, "application/pdf") { Name = huge };
+ ChatMessage msg = new(ChatRole.User, [dc]);
+
+ DetectedAttachment one = Assert.Single(AttachmentDetector.Detect([msg]));
+ Assert.Equal(255, one.Filename.Length);
+ }
+
+ [Fact]
+ // security: when sanitization removes everything (filename was *only* control chars / separators),
+ // fall back to the content-hash synthesizer rather than emitting an empty key.
+ public void DetectsDataContent_FallsBackToSynthesize_WhenSanitizedFilenameEmpty()
+ {
+ DataContent dc = new(s_pdfBytes, "application/pdf") { Name = "\x01\x02\x03" };
+ ChatMessage msg = new(ChatRole.User, [dc]);
+
+ DetectedAttachment one = Assert.Single(AttachmentDetector.Detect([msg]));
+ Assert.Matches("^attachment-[0-9a-f]{12}\\.pdf$", one.Filename);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderPhase5Tests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderPhase5Tests.cs
new file mode 100644
index 0000000000..21f73c92c4
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderPhase5Tests.cs
@@ -0,0 +1,244 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.AI.ContentUnderstanding;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 5 — single-document happy path: detect → analyze → render → rebuild messages
+/// (Strategy C) → inject system note. All tests substitute the analyze pipeline via
+/// AnalyzeOverride; no test in this file hits the network.
+///
+public sealed class ContextProviderPhase5Tests
+{
+ private static readonly Uri s_testEndpoint = SharedTestFixtures.TestEndpoint;
+
+ private static readonly byte[] s_pdfBytes = SharedTestFixtures.LoadFixturePdf();
+
+ [Fact]
+ public async Task InvokingAsync_StripsAttachment_AndInjectsRenderedDocument()
+ {
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(
+ Completed: true,
+ Result: MakeInvoiceResult(),
+ OperationId: "op-1",
+ Error: null,
+ Duration: TimeSpan.FromMilliseconds(42)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+
+ AgentSessionFake session = new();
+ DataContent pdfAttachment = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ ChatMessage userMessage = new(ChatRole.User,
+ [new TextContent("Summarize this invoice."), pdfAttachment]);
+
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(),
+ session,
+ new AIContext { Messages = new List { userMessage } }),
+ CancellationToken.None);
+
+ Assert.Equal(1, analyzer.CallCount);
+ Assert.Equal(("invoice.pdf", AnalyzerSelector.DocumentAnalyzer), analyzer.Calls[0]);
+
+ List messages = result.Messages!.ToList();
+ // Original user message preserved (minus the DataContent) + injected system note.
+ Assert.Equal(2, messages.Count);
+
+ ChatMessage rebuiltUser = messages[0];
+ Assert.Equal(ChatRole.User, rebuiltUser.Role);
+ Assert.DoesNotContain(rebuiltUser.Contents, c => c is DataContent);
+ Assert.Single(rebuiltUser.Contents);
+ Assert.Equal("Summarize this invoice.", ((TextContent)rebuiltUser.Contents[0]).Text);
+
+ ChatMessage systemNote = messages[1];
+ Assert.Equal(ChatRole.System, systemNote.Role);
+ Assert.True(systemNote.Contents.Count >= 2);
+ Assert.IsType(systemNote.Contents[0]);
+ Assert.Contains("pre-analyzed", ((TextContent)systemNote.Contents[0]).Text, StringComparison.OrdinalIgnoreCase);
+ Assert.IsType(systemNote.Contents[1]);
+ Assert.Contains("CONTOSO LTD.", ((TextContent)systemNote.Contents[1]).Text, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_DuplicateFilenameInSameSession_SilentlySkippedAcrossTurns()
+ {
+ // Cross-turn duplicate filename (e.g. DevUI conversation history re-includes
+ // the original input_file on every subsequent request). Expected behavior: the
+ // binary is stripped, the analyzer is NOT invoked again, the existing Ready entry
+ // is re-injected into the LLM context (because hosted UIs do not preserve the
+ // provider's previously-injected System note across turns), and NO rejection note
+ // is surfaced (which would otherwise cause the LLM to nag the user to rename a
+ // file they didn't intentionally re-upload).
+ AnalysisOutcome success = new(true, MakeInvoiceResult(), "op-1", null, TimeSpan.Zero);
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns("invoice.pdf", success);
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+ AgentSessionFake session = new();
+
+ DataContent first = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ _ = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { new(ChatRole.User, [first]) } }),
+ CancellationToken.None);
+
+ DataContent second = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { new(ChatRole.User, [second]) } }),
+ CancellationToken.None);
+
+ Assert.Equal(1, analyzer.CallCount);
+
+ List messages = result.Messages!.ToList();
+ Assert.DoesNotContain(messages.SelectMany(m => m.Contents), c => c is DataContent);
+
+ Assert.DoesNotContain(messages, m =>
+ m.Role == ChatRole.System
+ && m.Contents.OfType().Any(t =>
+ t.Text.Contains("already uploaded", StringComparison.Ordinal)
+ && t.Text.Contains("rename", StringComparison.Ordinal)));
+
+ // The Ready document was re-injected so the LLM can answer from it this turn.
+ Assert.Contains(messages, m =>
+ m.Role == ChatRole.System
+ && m.Contents.OfType().Any(t =>
+ t.Text.Contains("CONTOSO LTD.", StringComparison.Ordinal)));
+
+ ContentUnderstandingProviderState state = provider.GetStateForTesting(session);
+ Assert.Single(state.Documents);
+ Assert.Equal(DocumentStatus.Ready, state.Documents["invoice.pdf"].Status);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_UnsupportedMediaType_PassesThroughUntouched()
+ {
+ FakeAnalyzer analyzer = new();
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+
+ // application/zip is not in SUPPORTED_MEDIA_TYPES — must pass through.
+ DataContent unsupported = new(new byte[] { 0x50, 0x4B, 0x03, 0x04 }, "application/zip") { Name = "archive.zip" };
+ ChatMessage userMessage = new(ChatRole.User, [new TextContent("Read this."), unsupported]);
+
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(),
+ new AgentSessionFake(),
+ new AIContext { Messages = new List { userMessage } }),
+ CancellationToken.None);
+
+ Assert.Equal(0, analyzer.CallCount);
+ List messages = result.Messages!.ToList();
+ // No system note added; original message reached the LLM unchanged.
+ Assert.Single(messages);
+ Assert.Same(userMessage, messages[0]);
+ }
+
+ [Fact]
+ public async Task EnsureClientAsync_LazyInit_IsIdempotentUnderConcurrentLoad()
+ {
+ CountingClientFactory factory = new();
+ ContentUnderstandingContextProvider provider = new(s_testEndpoint, new FakeTokenCredential())
+ {
+ ClientFactoryOverride = factory,
+ };
+
+ await using (provider)
+ {
+ Assert.Equal(0, factory.CallCount); // Ctor never hits the factory.
+
+ Task[] callers = Enumerable.Range(0, 16)
+ .Select(_ => provider.EnsureClientForTestingAsync(CancellationToken.None).AsTask())
+ .ToArray();
+
+ ContentUnderstandingClient[] results = await Task.WhenAll(callers);
+
+ Assert.Equal(1, factory.CallCount);
+ Assert.All(results, c => Assert.Same(results[0], c));
+ }
+ }
+
+ [Fact]
+ public async Task DisposeAsync_IsIdempotent_AfterInvokingPath()
+ {
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, MakeInvoiceResult(), "op", null, TimeSpan.FromMilliseconds(1)));
+
+ ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ _ = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), new AgentSessionFake(),
+ new AIContext { Messages = new List { new(ChatRole.User, [pdf]) } }),
+ CancellationToken.None);
+
+ await provider.DisposeAsync();
+ await provider.DisposeAsync(); // second call must not throw.
+ }
+
+ [Fact]
+ public async Task InvokingAsync_AfterDispose_Throws()
+ {
+ FakeAnalyzer analyzer = new();
+ ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+ await provider.DisposeAsync();
+
+ await Assert.ThrowsAsync(() =>
+ provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), new AgentSessionFake(),
+ new AIContext { Messages = new List { new(ChatRole.User, "hi") } }),
+ CancellationToken.None).AsTask());
+ }
+
+ [Fact]
+ public async Task InvokingAsync_AnalysisFailure_MarksFailed_StillStripsAttachment()
+ {
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(
+ Completed: false,
+ Result: null,
+ OperationId: null,
+ Error: new InvalidOperationException("CU service rejected the request."),
+ Duration: TimeSpan.FromMilliseconds(5)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ ChatMessage user = new(ChatRole.User, [new TextContent("Read."), pdf]);
+
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), new AgentSessionFake(),
+ new AIContext { Messages = new List { user } }),
+ CancellationToken.None);
+
+ List messages = result.Messages!.ToList();
+ // No system note (no successful render); but the attachment is still stripped.
+ Assert.Single(messages);
+ Assert.DoesNotContain(messages[0].Contents, c => c is DataContent);
+ }
+
+ private static ContentUnderstandingContextProvider CreateProvider(FakeAnalyzer analyzer) =>
+ new(s_testEndpoint, new FakeTokenCredential())
+ {
+ // The lazy-init seam is exercised independently; analysis path here is fully mocked.
+ ClientFactoryOverride = new CountingClientFactory(),
+ AnalyzeOverride = analyzer.AnalyzeAsync,
+ };
+
+ private static AnalysisResult MakeInvoiceResult() => SharedTestFixtures.MakeInvoiceResult();
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderPhase6Tests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderPhase6Tests.cs
new file mode 100644
index 0000000000..9896c642b5
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderPhase6Tests.cs
@@ -0,0 +1,238 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.AI.ContentUnderstanding;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 6 — timeout-then-resume promotion. When the foreground attempt exceeds
+/// MaxWait, the provider stores a rehydration token on the ;
+/// the next InvokingAsync call replays it via the resume path and promotes the entry
+/// in place. Tests substitute the foreground call via AnalyzeOverride and the resume
+/// call via ResumeOverride; no test in this file hits the network.
+///
+public sealed class ContextProviderPhase6Tests
+{
+ private static readonly byte[] s_pdfBytes = SharedTestFixtures.LoadFixturePdf();
+
+ [Fact]
+ public async Task InvokingAsync_TimeoutThenResume_PromotesOnNextTurn()
+ {
+ AnalysisResult readyResult = SharedTestFixtures.MakeInvoiceResult();
+ AnalysisOutcome timeoutOutcome = new(
+ Completed: false,
+ Result: null,
+ OperationId: "op-123",
+ Error: null,
+ Duration: TimeSpan.FromMilliseconds(10))
+ {
+ RehydrationTokenJson = "rt-json-stub",
+ };
+
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns("invoice.pdf", timeoutOutcome);
+ FakeResumer resumer = new FakeResumer().Returns(
+ "op-123",
+ new AnalysisOutcome(
+ Completed: true,
+ Result: readyResult,
+ OperationId: "op-123",
+ Error: null,
+ Duration: TimeSpan.FromMilliseconds(200)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer, resumer);
+
+ AgentSessionFake session = new();
+ TestAIAgentStub agent = new();
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+
+ // Turn 1 — attempt times out. Document tracked as Analyzing with a rehydration token.
+ ChatMessage turn1User = new(ChatRole.User, [new TextContent("Read this."), pdf]);
+ AIContext turn1 = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ agent,
+ session,
+ new AIContext { Messages = new List { turn1User } }),
+ CancellationToken.None);
+
+ List turn1Messages = turn1.Messages!.ToList();
+ Assert.Single(turn1Messages);
+ Assert.DoesNotContain(turn1Messages[0].Contents, c => c is DataContent);
+ Assert.Equal(1, analyzer.CallCount);
+ Assert.Equal(0, resumer.CallCount);
+
+ ContentUnderstandingProviderState state = provider.GetStateForTesting(session);
+ Assert.Equal(DocumentStatus.Analyzing, state.Documents["invoice.pdf"].Status);
+ Assert.Equal("op-123", state.Documents["invoice.pdf"].OperationId);
+ Assert.Equal("rt-json-stub", state.Documents["invoice.pdf"].RehydrationTokenJson);
+ Assert.Empty(state.InjectedKeys);
+
+ // Turn 2 — user asks something else, no new attachment. Provider should resume the
+ // pending LRO via ResumeOverride, promote the doc, then inject it.
+ ChatMessage turn2User = new(ChatRole.User, [new TextContent("Now summarize it.")]);
+ AIContext turn2 = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ agent,
+ session,
+ new AIContext { Messages = new List { turn2User } }),
+ CancellationToken.None);
+
+ Assert.Equal(1, resumer.CallCount);
+ Assert.Equal(DocumentStatus.Ready, state.Documents["invoice.pdf"].Status);
+ Assert.NotNull(state.Documents["invoice.pdf"].Result);
+ Assert.Null(state.Documents["invoice.pdf"].RehydrationTokenJson);
+
+ List turn2Messages = turn2.Messages!.ToList();
+ Assert.Equal(2, turn2Messages.Count);
+ Assert.Equal(ChatRole.System, turn2Messages[1].Role);
+ string injectedText = string.Concat(turn2Messages[1].Contents.OfType().Select(t => t.Text));
+ Assert.Contains("CONTOSO LTD.", injectedText, StringComparison.Ordinal);
+
+ Assert.Contains("invoice.pdf", state.InjectedKeys);
+ // Foreground analyzer was only called turn 1.
+ Assert.Equal(1, analyzer.CallCount);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_PromotedDocument_NotReinjectedOnSubsequentTurn()
+ {
+ AnalysisResult readyResult = SharedTestFixtures.MakeInvoiceResult();
+ AnalysisOutcome timeoutOutcome = new(
+ Completed: false,
+ Result: null,
+ OperationId: "op-1",
+ Error: null,
+ Duration: TimeSpan.FromMilliseconds(5))
+ {
+ RehydrationTokenJson = "rt-json-stub",
+ };
+
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns("invoice.pdf", timeoutOutcome);
+ FakeResumer resumer = new FakeResumer().Returns(
+ "op-1",
+ new AnalysisOutcome(true, readyResult, "op-1", null, TimeSpan.FromMilliseconds(50)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer, resumer);
+
+ AgentSessionFake session = new();
+ TestAIAgentStub agent = new();
+
+ // Turn 1 — drive the analyzing path.
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(agent, session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ // Turn 2 — resume completes, injection happens once.
+ AIContext turn2 = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(agent, session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Summary?")]) } }),
+ CancellationToken.None);
+ Assert.Equal(2, turn2.Messages!.ToList().Count);
+
+ // Turn 3 — no re-injection. Only the user message survives.
+ AIContext turn3 = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(agent, session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Anything else?")]) } }),
+ CancellationToken.None);
+ List turn3Messages = turn3.Messages!.ToList();
+ Assert.Single(turn3Messages);
+ Assert.Equal(ChatRole.User, turn3Messages[0].Role);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_ResumeFails_StoresErrorAndDropsToken()
+ {
+ InvalidOperationException expected = new("simulated server failure");
+ AnalysisOutcome timeoutOutcome = new(false, null, "op-fail", null, TimeSpan.FromMilliseconds(5))
+ {
+ RehydrationTokenJson = "rt-json-stub",
+ };
+
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns("invoice.pdf", timeoutOutcome);
+ FakeResumer resumer = new FakeResumer().Returns(
+ "op-fail",
+ () => throw expected);
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer, resumer);
+
+ AgentSessionFake session = new();
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ ChatMessage user = new(ChatRole.User, [new TextContent("Read."), pdf]);
+
+ // Turn 1 — timeout, entry stored as Analyzing.
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { user } }),
+ CancellationToken.None);
+
+ // Turn 2 — resume throws; provider should mark the entry Failed without rethrowing.
+ AIContext next = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Still?")]) } }),
+ CancellationToken.None);
+
+ ContentUnderstandingProviderState state = provider.GetStateForTesting(session);
+ DocumentEntry entry = state.Documents["invoice.pdf"];
+ Assert.Equal(DocumentStatus.Failed, entry.Status);
+ Assert.Equal("simulated server failure", entry.Error);
+ Assert.Null(entry.RehydrationTokenJson);
+
+ // Failed docs are NOT injected (only Ready docs are).
+ List nextMessages = next.Messages!.ToList();
+ Assert.Single(nextMessages);
+ Assert.Equal(ChatRole.User, nextMessages[0].Role);
+ }
+
+ [Fact]
+ public async Task DisposeAsync_WithPendingAnalyzingEntry_ReturnsPromptly()
+ {
+ // With the Plan-C rewrite there is no background runner to cancel. DisposeAsync
+ // should simply return and leave the entry as Analyzing (the rehydration token can
+ // be picked up by a future provider instance if it shares the same session state).
+ AnalysisOutcome timeoutOutcome = new(false, null, "op-disposed", null, TimeSpan.FromMilliseconds(5))
+ {
+ RehydrationTokenJson = "rt-json-stub",
+ };
+
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns("invoice.pdf", timeoutOutcome);
+ ContentUnderstandingContextProvider provider = CreateProvider(analyzer, resumer: null);
+
+ AgentSessionFake session = new();
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ ChatMessage user = new(ChatRole.User, [new TextContent("Read."), pdf]);
+
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { user } }),
+ CancellationToken.None);
+
+ Stopwatch sw = Stopwatch.StartNew();
+ await provider.DisposeAsync();
+ sw.Stop();
+ Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2),
+ $"DisposeAsync took {sw.Elapsed} — expected near-instant return.");
+
+ // Status untouched.
+ ContentUnderstandingProviderState state = provider.GetStateForTesting(session);
+ Assert.Equal(DocumentStatus.Analyzing, state.Documents["invoice.pdf"].Status);
+ Assert.Equal("rt-json-stub", state.Documents["invoice.pdf"].RehydrationTokenJson);
+ }
+
+ private static ContentUnderstandingContextProvider CreateProvider(
+ FakeAnalyzer analyzer,
+ FakeResumer? resumer = null) =>
+ new(SharedTestFixtures.TestEndpoint, new FakeTokenCredential())
+ {
+ ClientFactoryOverride = new CountingClientFactory(),
+ AnalyzeOverride = analyzer.AnalyzeAsync,
+ ResumeOverride = resumer is null ? null : resumer.ResumeAsync,
+ };
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderPhase7Tests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderPhase7Tests.cs
new file mode 100644
index 0000000000..dec97c821d
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderPhase7Tests.cs
@@ -0,0 +1,247 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.AI.ContentUnderstanding;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 7 — auto-registered tools (list_documents, get_analyzed_document).
+/// Verifies the provider's AIContext.Tools wiring plus tool behavior (live state,
+/// section selection, unknown-name / still-analyzing error strings).
+///
+public sealed class ContextProviderPhase7Tests
+{
+ private static readonly byte[] s_pdfBytes = SharedTestFixtures.LoadFixturePdf();
+
+ [Fact]
+ public async Task InvokingAsync_NoDocuments_DoesNotSurfaceTools()
+ {
+ FakeAnalyzer analyzer = new();
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(),
+ new AgentSessionFake(),
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Hello.")]) } }),
+ CancellationToken.None);
+
+ Assert.Null(result.Tools);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_WithReadyDocument_SurfacesBothTools()
+ {
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(50)));
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(),
+ new AgentSessionFake(),
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ List tools = result.Tools!.ToList();
+ Assert.Equal(2, tools.Count);
+ Assert.Contains(tools, t => t is AIFunction f && f.Name == "list_documents");
+ Assert.Contains(tools, t => t is AIFunction f && f.Name == "get_analyzed_document");
+ }
+
+ [Fact]
+ public async Task InvokingAsync_StableToolSurface_AcrossTurns()
+ {
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(50)));
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+ AgentSessionFake session = new();
+
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ AIContext turn1 = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ AIContext turn2 = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("More?")]) } }),
+ CancellationToken.None);
+
+ // The tools are rebuilt each turn (bound to that turn's per-session state to keep
+ // concurrent sessions isolated), so they are NOT required to be the same reference.
+ // The contract the LLM relies on is a stable tool *surface*: same names present every
+ // turn with matching invocation schemas.
+ Dictionary t1 = turn1.Tools!.OfType().ToDictionary(f => f.Name, f => f);
+ Dictionary t2 = turn2.Tools!.OfType().ToDictionary(f => f.Name, f => f);
+ Assert.Contains("list_documents", t2.Keys);
+ Assert.Contains("get_analyzed_document", t2.Keys);
+ Assert.Equal(t1.Keys.OrderBy(k => k, StringComparer.Ordinal), t2.Keys.OrderBy(k => k, StringComparer.Ordinal));
+ Assert.Equal(t1["list_documents"].JsonSchema.ToString(), t2["list_documents"].JsonSchema.ToString());
+ Assert.Equal(t1["get_analyzed_document"].JsonSchema.ToString(), t2["get_analyzed_document"].JsonSchema.ToString());
+ }
+
+ [Fact]
+ public async Task ListDocumentsTool_ReflectsPostPromotionState()
+ {
+ AnalysisResult readyResult = SharedTestFixtures.MakeInvoiceResult();
+ AnalysisOutcome timeoutOutcome = new(
+ Completed: false,
+ Result: null,
+ OperationId: "op-1",
+ Error: null,
+ Duration: TimeSpan.FromMilliseconds(5))
+ {
+ RehydrationTokenJson = "rt-json-stub",
+ };
+
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns("invoice.pdf", timeoutOutcome);
+ FakeResumer resumer = new FakeResumer().Returns(
+ "op-1",
+ new AnalysisOutcome(true, readyResult, "op-1", null, TimeSpan.FromMilliseconds(100)));
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer, resumer);
+ AgentSessionFake session = new();
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+
+ // Turn 1 — document is Analyzing.
+ AIContext turn1 = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+ AIFunction list = turn1.Tools!.OfType().First(f => f.Name == "list_documents");
+
+ // Invoke the tool now — should see Analyzing.
+ AIFunctionArguments noArgs = new();
+ object? snapshot1 = await list.InvokeAsync(noArgs, CancellationToken.None);
+ Assert.Contains("Analyzing", snapshot1!.ToString(), StringComparison.Ordinal);
+ Assert.DoesNotContain("Ready", snapshot1!.ToString(), StringComparison.Ordinal);
+
+ // Turn 2 — resume promotes the entry in place.
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Done?")]) } }),
+ CancellationToken.None);
+
+ // Same AIFunction instance now sees Ready.
+ object? snapshot2 = await list.InvokeAsync(noArgs, CancellationToken.None);
+ Assert.Contains("Ready", snapshot2!.ToString(), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task GetAnalyzedDocumentTool_Default_ReturnsFullRender_Markdown_StripsFields()
+ {
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(50)));
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), new AgentSessionFake(),
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ AIFunction get = result.Tools!.OfType().First(f => f.Name == "get_analyzed_document");
+
+ AIFunctionArguments defaultArgs = new() { ["documentName"] = "invoice.pdf" };
+ string defaultRendered = (await get.InvokeAsync(defaultArgs, CancellationToken.None))!.ToString()!;
+ Assert.Contains("CONTOSO LTD.", defaultRendered, StringComparison.Ordinal);
+ Assert.Contains("fields:", defaultRendered, StringComparison.Ordinal);
+
+ AIFunctionArguments markdownArgs = new()
+ {
+ ["documentName"] = "invoice.pdf",
+ ["section"] = AnalysisSection.Markdown,
+ };
+ string markdownOnly = (await get.InvokeAsync(markdownArgs, CancellationToken.None))!.ToString()!;
+ Assert.Contains("CONTOSO LTD.", markdownOnly, StringComparison.Ordinal);
+ Assert.DoesNotContain("fields:", markdownOnly, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task GetAnalyzedDocumentTool_UnknownDocument_ReturnsErrorString()
+ {
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(50)));
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), new AgentSessionFake(),
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ AIFunction get = result.Tools!.OfType().First(f => f.Name == "get_analyzed_document");
+ AIFunctionArguments args = new() { ["documentName"] = "missing.pdf" };
+ string response = (await get.InvokeAsync(args, CancellationToken.None))!.ToString()!;
+ Assert.Equal("Document 'missing.pdf' not found", response);
+ }
+
+ [Fact]
+ public async Task GetAnalyzedDocumentTool_EmptyName_ReturnsRequiredErrorString()
+ {
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(50)));
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), new AgentSessionFake(),
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ AIFunction get = result.Tools!.OfType().First(f => f.Name == "get_analyzed_document");
+ AIFunctionArguments args = new() { ["documentName"] = string.Empty };
+ string response = (await get.InvokeAsync(args, CancellationToken.None))!.ToString()!;
+ Assert.Equal("Document name is required", response);
+ }
+
+ [Fact]
+ public async Task GetAnalyzedDocumentTool_StillAnalyzing_ReturnsStatusErrorString()
+ {
+ // Turn 1 records the entry as Analyzing with a token. With no ResumeOverride the
+ // get_analyzed_document tool should still observe the Analyzing status before any
+ // subsequent InvokingAsync triggers a resume attempt.
+ AnalysisOutcome timeoutOutcome = new(false, null, "op-1", null, TimeSpan.FromMilliseconds(5))
+ {
+ RehydrationTokenJson = "rt-json-stub",
+ };
+
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns("invoice.pdf", timeoutOutcome);
+ ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), new AgentSessionFake(),
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ AIFunction get = result.Tools!.OfType().First(f => f.Name == "get_analyzed_document");
+ AIFunctionArguments args = new() { ["documentName"] = "invoice.pdf" };
+ string response = (await get.InvokeAsync(args, CancellationToken.None))!.ToString()!;
+ Assert.Equal("Document 'invoice.pdf' is still Analyzing", response);
+
+ await provider.DisposeAsync();
+ }
+
+ private static ContentUnderstandingContextProvider CreateProvider(
+ FakeAnalyzer analyzer,
+ FakeResumer? resumer = null) =>
+ new(SharedTestFixtures.TestEndpoint, new FakeTokenCredential())
+ {
+ ClientFactoryOverride = new CountingClientFactory(),
+ AnalyzeOverride = analyzer.AnalyzeAsync,
+ ResumeOverride = resumer is null ? null : resumer.ResumeAsync,
+ };
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderPhase9Tests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderPhase9Tests.cs
new file mode 100644
index 0000000000..3936951d59
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderPhase9Tests.cs
@@ -0,0 +1,422 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.AI.ContentUnderstanding;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 9 — FileSearchConfig wiring through .
+/// Covers: vector-store uploads on ready, message-injection skip, tool/instructions surfacing,
+/// empty-payload skip, failure path, cross-turn promotion, and disposal cleanup.
+///
+public sealed class ContextProviderPhase9Tests
+{
+ private static readonly byte[] s_pdfBytes = SharedTestFixtures.LoadFixturePdf();
+
+ [Fact]
+ public async Task InvokingAsync_WithFileSearchConfig_UploadsAndSurfacesToolAndInstructions()
+ {
+ FakeFileSearchBackend backend = new();
+ FakeAITool fileSearchTool = new();
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(50)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(
+ analyzer,
+ backend,
+ fileSearchTool,
+ vectorStoreId: "vs-abc",
+ outputSections: AnalysisSection.Markdown);
+
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(),
+ new AgentSessionFake(),
+ new AIContext
+ {
+ Instructions = "You are helpful.",
+ Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) },
+ }),
+ CancellationToken.None);
+
+ // Exactly one upload, with the expected vector store id and `.md` suffix.
+ FakeFileSearchBackend.UploadCall upload = Assert.Single(backend.UploadCalls);
+ Assert.Equal("vs-abc", upload.VectorStoreId);
+ Assert.Equal("invoice.pdf.md", upload.Filename);
+ Assert.Contains("CONTOSO LTD.", upload.Payload, StringComparison.Ordinal);
+ // OutputSections=Markdown only → no fields block in the uploaded payload.
+ Assert.DoesNotContain("fields:", upload.Payload, StringComparison.Ordinal);
+
+ // file_search tool was appended to AIContext.Tools.
+ List tools = result.Tools!.ToList();
+ Assert.Contains(fileSearchTool, tools);
+ // The two built-in CU tools are still there too.
+ Assert.Contains(tools, t => t is AIFunction f && f.Name == "list_documents");
+ Assert.Contains(tools, t => t is AIFunction f && f.Name == "get_analyzed_document");
+
+ // Instructions extended with guidance.
+ Assert.NotNull(result.Instructions);
+ Assert.Contains("You are helpful.", result.Instructions, StringComparison.Ordinal);
+ Assert.Contains("Tool usage guidelines", result.Instructions, StringComparison.Ordinal);
+ Assert.Contains("file_search", result.Instructions, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_WithFileSearchConfig_DoesNotInjectFullDocumentBodyIntoMessages()
+ {
+ FakeFileSearchBackend backend = new();
+ FakeAITool fileSearchTool = new();
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(50)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(
+ analyzer, backend, fileSearchTool);
+
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(),
+ new AgentSessionFake(),
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ // Every message + content combined.
+ string combinedMessageText = string.Join(
+ "\n",
+ result.Messages!.SelectMany(m => m.Contents).OfType().Select(t => t.Text));
+
+ // Short note must be present.
+ Assert.Contains("invoice.pdf", combinedMessageText, StringComparison.Ordinal);
+ Assert.Contains("indexed in vector store", combinedMessageText, StringComparison.Ordinal);
+
+ // The full markdown body must NOT have been injected (vector store carries it instead).
+ Assert.DoesNotContain("CONTOSO LTD.", combinedMessageText, StringComparison.Ordinal);
+ Assert.DoesNotContain("# INVOICE", combinedMessageText, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_FilenameWithMarkdownSpecialChars_SanitizedOnUploadAndInNote()
+ {
+ // Filenames containing CommonMark-significant characters (especially `_`) render as
+ // italics in chat UIs whenever the model emits the name without wrapping it in
+ // backticks. The provider must replace those characters with `-` BEFORE the name
+ // surfaces in either the vector-store registration or the per-document System note,
+ // so the model can never echo back a name that breaks rendering. Original Filename
+ // is preserved for state keys.
+ FakeFileSearchBackend backend = new();
+ FakeAITool fileSearchTool = new();
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "mixed_financial_invoices.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(20)));
+
+ AgentSessionFake session = new();
+ await using ContentUnderstandingContextProvider provider = CreateProvider(
+ analyzer, backend, fileSearchTool);
+
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "mixed_financial_invoices.pdf" };
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ // Upload registers the sanitized name (no underscores).
+ FakeFileSearchBackend.UploadCall upload = Assert.Single(backend.UploadCalls);
+ Assert.Equal("mixed-financial-invoices.pdf.md", upload.Filename);
+
+ // Injected System note uses the sanitized name as well — model can echo verbatim
+ // without breaking the chat-UI markdown renderer.
+ string combinedMessageText = string.Join(
+ "\n",
+ result.Messages!.SelectMany(m => m.Contents).OfType().Select(t => t.Text));
+ Assert.Contains("mixed-financial-invoices.pdf", combinedMessageText, StringComparison.Ordinal);
+ Assert.DoesNotContain("mixed_financial_invoices.pdf", combinedMessageText, StringComparison.Ordinal);
+
+ // State still keys on the original filename so cross-turn dedup keeps working.
+ ContentUnderstandingProviderState st = provider.GetStateForTesting(session);
+ Assert.True(st.Documents.ContainsKey("mixed_financial_invoices.pdf"));
+ Assert.Equal("mixed_financial_invoices.pdf", st.Documents["mixed_financial_invoices.pdf"].Filename);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_WithOutputSectionsIncludingFields_UploadPayloadContainsFieldsBlock()
+ {
+ FakeFileSearchBackend backend = new();
+ FakeAITool fileSearchTool = new();
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(50)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(
+ analyzer, backend, fileSearchTool, outputSections: AnalysisSection.Markdown | AnalysisSection.Fields);
+
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(),
+ new AgentSessionFake(),
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ FakeFileSearchBackend.UploadCall upload = Assert.Single(backend.UploadCalls);
+ Assert.Contains("fields:", upload.Payload, StringComparison.Ordinal);
+ Assert.Contains("CONTOSO LTD.", upload.Payload, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_EmptyRenderableBody_SkipsUploadAndEmitsNote()
+ {
+ // Make an AnalysisResult whose rendering has front-matter only (no body content).
+ AnalysisResult emptyContent = ContentUnderstandingModelFactory.AnalysisResult(
+ contents:
+ [
+ ContentUnderstandingModelFactory.DocumentContent(
+ mimeType: "application/pdf",
+ markdown: " ",
+ fields: null,
+ startPageNumber: 1,
+ endPageNumber: 1),
+ ]);
+
+ FakeFileSearchBackend backend = new();
+ FakeAITool fileSearchTool = new();
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "blank.pdf",
+ new AnalysisOutcome(true, emptyContent, "op-1", null, TimeSpan.FromMilliseconds(50)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(
+ analyzer, backend, fileSearchTool);
+ AgentSessionFake session = new();
+
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "blank.pdf" };
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(),
+ session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ // No upload happened.
+ Assert.Empty(backend.UploadCalls);
+
+ // The entry remains Ready (this is not a failure path).
+ ContentUnderstandingProviderState st = provider.GetStateForTesting(session);
+ DocumentEntry entry = st.Documents["blank.pdf"];
+ Assert.Equal(DocumentStatus.Ready, entry.Status);
+ Assert.Null(entry.VectorStoreFileId);
+
+ // A short skip note is emitted to the LLM.
+ string combinedText = string.Join("\n",
+ result.Messages!.SelectMany(m => m.Contents).OfType().Select(t => t.Text));
+ Assert.Contains("blank.pdf", combinedText, StringComparison.Ordinal);
+ Assert.Contains("no searchable text", combinedText, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_UploadBudgetExhausted_DefersUploadButKeepsReadyResult()
+ {
+ // Analysis consumes the entire MaxWait budget (Duration 1s >> MaxWait 10ms), leaving
+ // zero foreground time for the vector-store upload. The upload must be DEFERRED, not
+ // failed: the analyzed result stays intact and Ready so list_documents /
+ // get_analyzed_document keep serving it, and the next turn's promotion scan retries the
+ // upload (VectorStoreFileId is still null). Regression guard for the budget-exhaustion
+ // path that previously discarded a valid analysis by marking it Failed.
+ FakeFileSearchBackend backend = new();
+ FakeAITool fileSearchTool = new();
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromSeconds(1)));
+
+ await using ContentUnderstandingContextProvider provider = new(
+ new ContentUnderstandingContextProviderOptions(SharedTestFixtures.TestEndpoint, new FakeTokenCredential())
+ {
+ MaxWait = TimeSpan.FromMilliseconds(10),
+ FileSearchConfig = new FileSearchConfig
+ {
+ Backend = backend,
+ VectorStoreId = "vs-abc",
+ FileSearchTool = fileSearchTool,
+ },
+ })
+ {
+ ClientFactoryOverride = new CountingClientFactory(),
+ AnalyzeOverride = analyzer.AnalyzeAsync,
+ };
+ AgentSessionFake session = new();
+
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(),
+ session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ // Upload was deferred, never attempted.
+ Assert.Empty(backend.UploadCalls);
+
+ // The analyzed result is preserved and still Ready (NOT marked Failed / cleared).
+ ContentUnderstandingProviderState st = provider.GetStateForTesting(session);
+ DocumentEntry entry = st.Documents["invoice.pdf"];
+ Assert.Equal(DocumentStatus.Ready, entry.Status);
+ Assert.Null(entry.VectorStoreFileId); // upload pending → next turn retries.
+ Assert.NotNull(entry.Result); // rendered content intact.
+ Assert.Contains("deferred", entry.Error!, StringComparison.OrdinalIgnoreCase);
+
+ // The LLM note explains the deferral rather than reporting an upload failure.
+ string combinedText = string.Join("\n",
+ result.Messages!.SelectMany(m => m.Contents).OfType().Select(t => t.Text));
+ Assert.Contains("deferred", combinedText, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("failed to upload", combinedText, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_BackendThrows_StatusBecomesFailedAndNoteEmitted()
+ {
+ FakeFileSearchBackend backend = new()
+ {
+ UploadHandler = (_, _) => throw new InvalidOperationException("simulated upload failure"),
+ };
+ FakeAITool fileSearchTool = new();
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(50)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(
+ analyzer, backend, fileSearchTool);
+ AgentSessionFake session = new();
+
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(),
+ session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ // Status moved to Failed.
+ ContentUnderstandingProviderState st = provider.GetStateForTesting(session);
+ DocumentEntry entry = st.Documents["invoice.pdf"];
+ Assert.Equal(DocumentStatus.Failed, entry.Status);
+ Assert.Equal("simulated upload failure", entry.Error);
+ Assert.Null(entry.VectorStoreFileId);
+
+ // Note mentions failure to LLM.
+ string combinedText = string.Join("\n",
+ result.Messages!.SelectMany(m => m.Contents).OfType().Select(t => t.Text));
+ Assert.Contains("failed to upload", combinedText, StringComparison.Ordinal);
+ Assert.Contains("simulated upload failure", combinedText, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task DisposeAsync_DeletesEveryUploadedFile()
+ {
+ FakeFileSearchBackend backend = new();
+ FakeAITool fileSearchTool = new();
+ FakeAnalyzer analyzer = new FakeAnalyzer()
+ .Returns("invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(50)))
+ .Returns("invoice2.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-2", null, TimeSpan.FromMilliseconds(50)));
+
+ ContentUnderstandingContextProvider provider = CreateProvider(analyzer, backend, fileSearchTool);
+ AgentSessionFake session = new();
+
+ DataContent pdf1 = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ DataContent pdf2 = new(s_pdfBytes, "application/pdf") { Name = "invoice2.pdf" };
+
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Two."), pdf1, pdf2]) } }),
+ CancellationToken.None);
+
+ Assert.Equal(2, backend.UploadCalls.Count);
+ Assert.Empty(backend.DeleteCalls);
+
+ await provider.DisposeAsync();
+
+ // Each uploaded file id should have been requested for deletion exactly once.
+ Assert.Equal(2, backend.DeleteCalls.Count);
+ HashSet deleted = new(backend.DeleteCalls, StringComparer.Ordinal);
+ // Fake ids start at file-0001, file-0002 ...
+ Assert.Contains("file-0001", deleted);
+ Assert.Contains("file-0002", deleted);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_BackgroundPromoted_UploadHappensOnNextTurn()
+ {
+ AnalysisResult readyResult = SharedTestFixtures.MakeInvoiceResult();
+ AnalysisOutcome timeoutOutcome = new(
+ Completed: false,
+ Result: null,
+ OperationId: "op-1",
+ Error: null,
+ Duration: TimeSpan.FromMilliseconds(5))
+ {
+ RehydrationTokenJson = "rt-json-stub",
+ };
+
+ FakeFileSearchBackend backend = new();
+ FakeAITool fileSearchTool = new();
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns("invoice.pdf", timeoutOutcome);
+ FakeResumer resumer = new FakeResumer().Returns(
+ "op-1",
+ new AnalysisOutcome(true, readyResult, "op-1", null, TimeSpan.FromMilliseconds(100)));
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer, backend, fileSearchTool, resumer: resumer);
+ AgentSessionFake session = new();
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+
+ // Turn 1 — analysis times out, entry is Analyzing, NO upload yet.
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+ Assert.Empty(backend.UploadCalls);
+
+ // Turn 2 — resume completes mid-turn → entry becomes Ready → cross-turn promotion uploads.
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Anything?")]) } }),
+ CancellationToken.None);
+
+ FakeFileSearchBackend.UploadCall upload = Assert.Single(backend.UploadCalls);
+ Assert.Equal("invoice.pdf.md", upload.Filename);
+ Assert.Contains("CONTOSO LTD.", upload.Payload, StringComparison.Ordinal);
+
+ ContentUnderstandingProviderState st = provider.GetStateForTesting(session);
+ Assert.Equal("file-0001", st.Documents["invoice.pdf"].VectorStoreFileId);
+ }
+
+ private static ContentUnderstandingContextProvider CreateProvider(
+ FakeAnalyzer analyzer,
+ FakeFileSearchBackend backend,
+ FakeAITool fileSearchTool,
+ string vectorStoreId = "vs-abc",
+ AnalysisSection outputSections = AnalysisSection.Default,
+ FakeResumer? resumer = null) =>
+ new(new ContentUnderstandingContextProviderOptions(SharedTestFixtures.TestEndpoint, new FakeTokenCredential())
+ {
+ OutputSections = outputSections,
+ FileSearchConfig = new FileSearchConfig
+ {
+ Backend = backend,
+ VectorStoreId = vectorStoreId,
+ FileSearchTool = fileSearchTool,
+ },
+ })
+ {
+ ClientFactoryOverride = new CountingClientFactory(),
+ AnalyzeOverride = analyzer.AnalyzeAsync,
+ ResumeOverride = resumer is null ? null : resumer.ResumeAsync,
+ };
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderTests.cs
new file mode 100644
index 0000000000..f700c159c5
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ContextProviderTests.cs
@@ -0,0 +1,109 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 2 / dev plan task 2.4 — provider constructor argument validation and StateKeys shape.
+///
+public sealed class ContextProviderTests
+{
+ private static readonly Uri s_testEndpoint = new("https://contoso.cognitiveservices.azure.com/");
+
+ [Fact]
+ public void OptionsConstructor_ThrowsOnNullOptions()
+ {
+ var ex = Assert.Throws(() => new ContentUnderstandingContextProvider(options: null!));
+ Assert.Equal("options", ex.ParamName);
+ }
+
+ [Fact]
+ public void OptionsConstructor_ThrowsWhenEndpointNotSetByObjectInitializer()
+ {
+ var options = new ContentUnderstandingContextProviderOptions
+ {
+ // Endpoint deliberately omitted
+ Credential = new FakeTokenCredential(),
+ };
+
+ var ex = Assert.Throws(() => new ContentUnderstandingContextProvider(options));
+ Assert.Equal("options", ex.ParamName);
+ Assert.Contains("Endpoint", ex.Message);
+ }
+
+ [Fact]
+ public void OptionsConstructor_ThrowsWhenCredentialNotSetByObjectInitializer()
+ {
+ var options = new ContentUnderstandingContextProviderOptions
+ {
+ Endpoint = s_testEndpoint,
+ // Credential deliberately omitted
+ };
+
+ var ex = Assert.Throws(() => new ContentUnderstandingContextProvider(options));
+ Assert.Equal("options", ex.ParamName);
+ Assert.Contains("Credential", ex.Message);
+ }
+
+ [Fact]
+ public void ConvenienceConstructor_ThrowsOnNullEndpoint()
+ {
+ var ex = Assert.Throws(() =>
+ new ContentUnderstandingContextProvider(endpoint: null!, credential: new FakeTokenCredential()));
+ Assert.Equal("endpoint", ex.ParamName);
+ }
+
+ [Fact]
+ public void ConvenienceConstructor_ThrowsOnNullCredential()
+ {
+ var ex = Assert.Throws(() =>
+ new ContentUnderstandingContextProvider(endpoint: s_testEndpoint, credential: null!));
+ Assert.Equal("credential", ex.ParamName);
+ }
+
+ [Fact]
+ public void OptionsConstructor_AppliesOptions()
+ {
+ var provider = new ContentUnderstandingContextProvider(
+ new ContentUnderstandingContextProviderOptions(s_testEndpoint, new FakeTokenCredential())
+ {
+ AnalyzerId = "prebuilt-invoice",
+ MaxWait = TimeSpan.FromSeconds(30),
+ OutputSections = AnalysisSection.Markdown,
+ });
+
+ // No public accessor to inspect options yet — but constructing without throwing confirms
+ // the options were accepted on a valid Options instance.
+ Assert.NotNull(provider);
+ }
+
+ [Fact]
+ public void StateKeys_ReturnsTypeFullName()
+ {
+ var provider = new ContentUnderstandingContextProvider(s_testEndpoint, new FakeTokenCredential());
+
+ Assert.Single(provider.StateKeys);
+ Assert.Equal(typeof(ContentUnderstandingContextProvider).FullName, provider.StateKeys[0]);
+ }
+
+ [Fact]
+ public void ProvideAIContextAsync_PhaseFiveNotImplemented()
+ {
+ // Phase 5 will implement this; Phase 2 ships only the shell.
+ // We don't invoke it here because InvokingContext requires non-trivial setup; ensuring
+ // the override exists is enforced by the compiler. This test pins the contract.
+ var provider = new ContentUnderstandingContextProvider(s_testEndpoint, new FakeTokenCredential());
+ Assert.NotNull(provider);
+ }
+
+ [Fact]
+ public async Task DisposeAsync_IsIdempotentNoOp()
+ {
+ var provider = new ContentUnderstandingContextProvider(s_testEndpoint, new FakeTokenCredential());
+
+ await provider.DisposeAsync();
+ await provider.DisposeAsync(); // second call must not throw
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/CoverageGapTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/CoverageGapTests.cs
new file mode 100644
index 0000000000..35edd18825
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/CoverageGapTests.cs
@@ -0,0 +1,283 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.AI.ContentUnderstanding;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 11 — provider-level coverage gaps:
+/// URL input, multi-file analysis, same-turn duplicate filename, supported-media-types,
+/// session isolation, and multi-file FileSearch upload.
+///
+public sealed class CoverageGapTests
+{
+ private static readonly Uri s_testEndpoint = SharedTestFixtures.TestEndpoint;
+ private static readonly byte[] s_pdfBytes = SharedTestFixtures.LoadFixturePdf();
+
+ [Fact]
+ public async Task InvokingAsync_UrlInput_AnalyzedAndInjected()
+ {
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "report.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(50)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+
+ AgentSessionFake session = new();
+ UriContent pdfUrl = new("https://example.com/report.pdf", "application/pdf");
+ ChatMessage userMessage = new(ChatRole.User, [new TextContent("Analyze this document"), pdfUrl]);
+
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(),
+ session,
+ new AIContext { Messages = new List { userMessage } }),
+ CancellationToken.None);
+
+ Assert.Equal(1, analyzer.CallCount);
+ Assert.Equal("report.pdf", analyzer.Calls[0].Filename);
+
+ ContentUnderstandingProviderState state = provider.GetStateForTesting(session);
+ Assert.True(state.Documents.ContainsKey("report.pdf"));
+ Assert.Equal(DocumentStatus.Ready, state.Documents["report.pdf"].Status);
+
+ List messages = result.Messages!.ToList();
+ Assert.Equal(2, messages.Count);
+ Assert.Equal(ChatRole.System, messages[1].Role);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_TwoAttachmentsInSameTurn_BothAnalyzed()
+ {
+ FakeAnalyzer analyzer = new FakeAnalyzer()
+ .Returns("doc1.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(20)))
+ .Returns("chart.png",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-2", null, TimeSpan.FromMilliseconds(20)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+
+ AgentSessionFake session = new();
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "doc1.pdf" };
+ DataContent png = new(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }, "image/png") { Name = "chart.png" };
+ ChatMessage userMessage = new(ChatRole.User,
+ [new TextContent("Compare these documents"), pdf, png]);
+
+ _ = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { userMessage } }),
+ CancellationToken.None);
+
+ Assert.Equal(2, analyzer.CallCount);
+
+ ContentUnderstandingProviderState state = provider.GetStateForTesting(session);
+ Assert.Equal(2, state.Documents.Count);
+ Assert.Equal(DocumentStatus.Ready, state.Documents["doc1.pdf"].Status);
+ Assert.Equal(DocumentStatus.Ready, state.Documents["chart.png"].Status);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_DuplicateFilenameInSameTurn_RejectedWithSystemNote()
+ {
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(20)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+ AgentSessionFake session = new();
+
+ DataContent first = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ DataContent second = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+ ChatMessage userMessage = new(ChatRole.User, [new TextContent("Two attachments same name"), first, second]);
+
+ AIContext result = await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), session,
+ new AIContext { Messages = new List { userMessage } }),
+ CancellationToken.None);
+
+ // First wins; analyzer invoked exactly once for the duplicate filename.
+ Assert.Equal(1, analyzer.CallCount);
+
+ ContentUnderstandingProviderState state = provider.GetStateForTesting(session);
+ Assert.Single(state.Documents);
+ Assert.Equal(DocumentStatus.Ready, state.Documents["invoice.pdf"].Status);
+
+ List messages = result.Messages!.ToList();
+ Assert.DoesNotContain(messages.SelectMany(m => m.Contents), c => c is DataContent);
+ // A System note carrying the rejection text is emitted.
+ Assert.Contains(messages, m =>
+ m.Role == ChatRole.System
+ && m.Contents.OfType().Any(t =>
+ t.Text.Contains("already uploaded", StringComparison.Ordinal)
+ && t.Text.Contains("rename", StringComparison.Ordinal)));
+ }
+
+ [Theory]
+ [InlineData("application/pdf", true)]
+ [InlineData("image/png", true)]
+ [InlineData("image/jpeg", true)]
+ [InlineData("audio/mpeg", true)]
+ [InlineData("audio/wav", true)]
+ [InlineData("video/mp4", true)]
+ [InlineData("text/plain", true)]
+ [InlineData("application/zip", false)]
+ [InlineData("application/json", false)]
+ public void SupportedMediaTypes_MatchesAllowList(string mediaType, bool expectedSupported)
+ {
+ DataContent dc = new(new byte[] { 0x00 }, mediaType) { Name = "sample.bin" };
+ ChatMessage msg = new(ChatRole.User, [dc]);
+
+ bool detected = AttachmentDetector.Detect([msg]).Any();
+ Assert.Equal(expectedSupported, detected);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_TwoSessions_HaveIsolatedRegistries()
+ {
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns(
+ "invoice.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(20)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer);
+
+ AgentSessionFake sessionA = new();
+ AgentSessionFake sessionB = new();
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+
+ // Session A registers a document.
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), sessionA,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ // Session B starts cold; its state must not see session A's document.
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), sessionB,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Hello.")]) } }),
+ CancellationToken.None);
+
+ ContentUnderstandingProviderState stateA = provider.GetStateForTesting(sessionA);
+ ContentUnderstandingProviderState stateB = provider.GetStateForTesting(sessionB);
+
+ Assert.True(stateA.Documents.ContainsKey("invoice.pdf"));
+ Assert.False(stateB.Documents.ContainsKey("invoice.pdf"));
+ }
+
+ [Fact]
+ public async Task BackgroundCompletion_ResolvesAgainstTheOriginatingSessionOnly()
+ {
+ AnalysisResult ready = SharedTestFixtures.MakeInvoiceResult();
+ AnalysisOutcome timeoutOutcome = new(
+ Completed: false,
+ Result: null,
+ OperationId: "op-1",
+ Error: null,
+ Duration: TimeSpan.FromMilliseconds(5))
+ {
+ RehydrationTokenJson = "rt-json-stub",
+ };
+
+ FakeAnalyzer analyzer = new FakeAnalyzer().Returns("invoice.pdf", timeoutOutcome);
+ FakeResumer resumer = new FakeResumer().Returns(
+ "op-1",
+ new AnalysisOutcome(true, ready, "op-1", null, TimeSpan.FromMilliseconds(80)));
+
+ await using ContentUnderstandingContextProvider provider = CreateProvider(analyzer, resumer);
+
+ AgentSessionFake sessionA = new();
+ AgentSessionFake sessionB = new();
+ DataContent pdf = new(s_pdfBytes, "application/pdf") { Name = "invoice.pdf" };
+
+ // Session A starts the analysis; it times out, entry stored under sessionA only.
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), sessionA,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Read."), pdf]) } }),
+ CancellationToken.None);
+
+ ContentUnderstandingProviderState stateAfterTurn1 = provider.GetStateForTesting(sessionA);
+ Assert.Equal(DocumentStatus.Analyzing, stateAfterTurn1.Documents["invoice.pdf"].Status);
+
+ // Session A turn 2: resume completes → entry promoted to Ready under sessionA.
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), sessionA,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Done?")]) } }),
+ CancellationToken.None);
+
+ // Session B never sees the document, even after sessionA promoted it.
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), sessionB,
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Hello.")]) } }),
+ CancellationToken.None);
+
+ ContentUnderstandingProviderState stateA = provider.GetStateForTesting(sessionA);
+ ContentUnderstandingProviderState stateB = provider.GetStateForTesting(sessionB);
+
+ Assert.Equal(DocumentStatus.Ready, stateA.Documents["invoice.pdf"].Status);
+ Assert.False(stateB.Documents.ContainsKey("invoice.pdf"));
+ Assert.Equal(1, resumer.CallCount);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_FileSearch_MultipleAttachments_UploadEach()
+ {
+ FakeFileSearchBackend backend = new();
+ FakeAITool fileSearchTool = new();
+ FakeAnalyzer analyzer = new FakeAnalyzer()
+ .Returns("a.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-1", null, TimeSpan.FromMilliseconds(20)))
+ .Returns("b.pdf",
+ new AnalysisOutcome(true, SharedTestFixtures.MakeInvoiceResult(), "op-2", null, TimeSpan.FromMilliseconds(20)));
+
+ await using ContentUnderstandingContextProvider provider = new(
+ new ContentUnderstandingContextProviderOptions(s_testEndpoint, new FakeTokenCredential())
+ {
+ FileSearchConfig = new FileSearchConfig
+ {
+ Backend = backend,
+ VectorStoreId = "vs-xyz",
+ FileSearchTool = fileSearchTool,
+ },
+ })
+ {
+ ClientFactoryOverride = new CountingClientFactory(),
+ AnalyzeOverride = analyzer.AnalyzeAsync,
+ };
+
+ DataContent a = new(s_pdfBytes, "application/pdf") { Name = "a.pdf" };
+ DataContent b = new(s_pdfBytes, "application/pdf") { Name = "b.pdf" };
+
+ await provider.InvokingAsync(
+ new AIContextProvider.InvokingContext(
+ new TestAIAgentStub(), new AgentSessionFake(),
+ new AIContext { Messages = new List { new(ChatRole.User, [new TextContent("Two."), a, b]) } }),
+ CancellationToken.None);
+
+ Assert.Equal(2, backend.UploadCalls.Count);
+ HashSet uploadedNames = new(backend.UploadCalls.Select(c => c.Filename), StringComparer.Ordinal);
+ Assert.Contains("a.pdf.md", uploadedNames);
+ Assert.Contains("b.pdf.md", uploadedNames);
+ }
+
+ private static ContentUnderstandingContextProvider CreateProvider(
+ FakeAnalyzer analyzer,
+ FakeResumer? resumer = null) =>
+ new(s_testEndpoint, new FakeTokenCredential())
+ {
+ ClientFactoryOverride = new CountingClientFactory(),
+ AnalyzeOverride = analyzer.AnalyzeAsync,
+ ResumeOverride = resumer is null ? null : resumer.ResumeAsync,
+ };
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/FileSearchConfigFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/FileSearchConfigFactoryTests.cs
new file mode 100644
index 0000000000..266062b887
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/FileSearchConfigFactoryTests.cs
@@ -0,0 +1,84 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Azure.AI.Projects;
+using OpenAI;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 11 — static factory helpers
+/// (FromOpenAI and FromFoundry).
+///
+public sealed class FileSearchConfigFactoryTests
+{
+ private static readonly FakeAITool s_fileSearchTool = new();
+
+ [Fact]
+ public void FromOpenAI_BuildsConfigWithOpenAIBackend()
+ {
+ OpenAIClient client = new("sk-fake-key");
+
+ FileSearchConfig config = FileSearchConfig.FromOpenAI(client, "vs_abc", s_fileSearchTool);
+
+ Assert.IsType(config.Backend);
+ Assert.Equal("vs_abc", config.VectorStoreId);
+ Assert.Same(s_fileSearchTool, config.FileSearchTool);
+ }
+
+ [Fact]
+ public void FromFoundry_BuildsConfigWithFoundryBackend()
+ {
+ AIProjectClient project = new(
+ new Uri("https://contoso.services.ai.azure.com/api/projects/test"),
+ new FakeTokenCredential());
+
+ FileSearchConfig config = FileSearchConfig.FromFoundry(project, "vs_xyz", s_fileSearchTool);
+
+ Assert.IsType(config.Backend);
+ Assert.Equal("vs_xyz", config.VectorStoreId);
+ Assert.Same(s_fileSearchTool, config.FileSearchTool);
+ }
+
+ [Fact]
+ public void FromOpenAI_RejectsNullArguments()
+ {
+ OpenAIClient client = new("sk-fake-key");
+
+ Assert.Throws(() => FileSearchConfig.FromOpenAI(null!, "vs", s_fileSearchTool));
+ Assert.Throws(() => FileSearchConfig.FromOpenAI(client, null!, s_fileSearchTool));
+ Assert.Throws(() => FileSearchConfig.FromOpenAI(client, "vs", null!));
+ }
+
+ [Fact]
+ public void FromFoundry_RejectsNullArguments()
+ {
+ AIProjectClient project = new(
+ new Uri("https://contoso.services.ai.azure.com/api/projects/test"),
+ new FakeTokenCredential());
+
+ Assert.Throws(() => FileSearchConfig.FromFoundry(null!, "vs", s_fileSearchTool));
+ Assert.Throws(() => FileSearchConfig.FromFoundry(project, null!, s_fileSearchTool));
+ Assert.Throws(() => FileSearchConfig.FromFoundry(project, "vs", null!));
+ }
+
+ [Fact]
+ public void FromOpenAI_RejectsWhitespaceVectorStoreId()
+ {
+ // Whitespace (non-null) must surface as ArgumentException, distinct from the
+ // ArgumentNullException thrown for a null id (xUnit Throws matches the exact type).
+ OpenAIClient client = new("sk-fake-key");
+
+ Assert.Throws(() => FileSearchConfig.FromOpenAI(client, " ", s_fileSearchTool));
+ }
+
+ [Fact]
+ public void FromFoundry_RejectsWhitespaceVectorStoreId()
+ {
+ AIProjectClient project = new(
+ new Uri("https://contoso.services.ai.azure.com/api/projects/test"),
+ new FakeTokenCredential());
+
+ Assert.Throws(() => FileSearchConfig.FromFoundry(project, " ", s_fileSearchTool));
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests.csproj
new file mode 100644
index 0000000000..d98fa4abce
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests.csproj
@@ -0,0 +1,11 @@
+
+
+
+ $(NoWarn);IDE1006;VSTHRD200
+
+
+
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/MimeSnifferTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/MimeSnifferTests.cs
new file mode 100644
index 0000000000..5c7567c07b
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/MimeSnifferTests.cs
@@ -0,0 +1,105 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 3 / dev plan task 3.1 — MIME byte-signature detection.
+///
+public sealed class MimeSnifferTests
+{
+ [Fact]
+ public void Detects_Pdf()
+ => Assert.Equal("application/pdf", MimeSniffer.Detect([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37]));
+
+ [Fact]
+ public void Detects_Png()
+ => Assert.Equal("image/png", MimeSniffer.Detect([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00]));
+
+ [Fact]
+ public void Detects_Jpeg()
+ => Assert.Equal("image/jpeg", MimeSniffer.Detect([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]));
+
+ [Fact]
+ public void Detects_Mp3_Id3()
+ {
+ // ID3v2 header (10 bytes, empty tag body) immediately followed by an MPEG audio frame sync
+ // word. synchsafe size = 0 -> tagSize = 10, so the frame begins at offset 10 and the
+ // sniffer confirms MP3 from the sync word that follows the tag.
+ byte[] head =
+ [
+ 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, // "ID3", version 2.3, flags
+ 0x00, 0x00, 0x00, 0x00, // synchsafe tag-body size = 0
+ 0xFF, 0xFB, 0x90, 0x00, // MPEG frame sync word after the tag
+ ];
+ Assert.Equal("audio/mpeg", MimeSniffer.Detect(head));
+ }
+
+ [Fact]
+ public void Detects_Mp3_FrameSync()
+ {
+ // A bare MPEG-1 Layer III frame (128 kbps, 44.1 kHz) is 417 bytes long. Detection requires
+ // a second sync word one frame later (double-sync), so supply two back-to-back headers.
+ const int FrameLength = 417;
+ byte[] head = new byte[FrameLength + 2];
+ head[0] = 0xFF;
+ head[1] = 0xFB;
+ head[2] = 0x90;
+ head[3] = 0x00;
+ head[FrameLength] = 0xFF;
+ head[FrameLength + 1] = 0xFB;
+ Assert.Equal("audio/mpeg", MimeSniffer.Detect(head));
+ }
+
+ [Fact]
+ public void Detects_Mp4()
+ {
+ // Bytes 4..8 = "ftyp"
+ byte[] head = [0x00, 0x00, 0x00, 0x20, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'i', (byte)'s', (byte)'o', (byte)'m'];
+ Assert.Equal("video/mp4", MimeSniffer.Detect(head));
+ }
+
+ [Fact]
+ public void Detects_Wav()
+ {
+ // "RIFF" + 4-byte size + "WAVE"
+ byte[] head = [(byte)'R', (byte)'I', (byte)'F', (byte)'F', 0x24, 0x00, 0x00, 0x00, (byte)'W', (byte)'A', (byte)'V', (byte)'E'];
+ Assert.Equal("audio/wav", MimeSniffer.Detect(head));
+ }
+
+ [Fact]
+ public void Detects_Flac()
+ {
+ // Bare FLAC stream begins with the "fLaC" magic (no ID3 wrapper).
+ byte[] head = [(byte)'f', (byte)'L', (byte)'a', (byte)'C', 0x00, 0x00, 0x00, 0x22];
+ Assert.Equal("audio/flac", MimeSniffer.Detect(head));
+ }
+
+ [Fact]
+ public void Detects_Ogg()
+ {
+ // Bare OGG container begins with the "OggS" capture pattern (no ID3 wrapper).
+ byte[] head = [(byte)'O', (byte)'g', (byte)'g', (byte)'S', 0x00, 0x02, 0x00, 0x00];
+ Assert.Equal("audio/ogg", MimeSniffer.Detect(head));
+ }
+
+ [Fact]
+ public void ReturnsNullForUnknownSignature()
+ {
+ byte[] head = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE];
+ Assert.Null(MimeSniffer.Detect(head));
+ }
+
+ [Fact]
+ public void ReturnsNullForEmpty()
+ => Assert.Null(MimeSniffer.Detect(ReadOnlySpan.Empty));
+
+ [Fact]
+ public void DoesNotMisdetect_ShortPdfPrefix()
+ {
+ // Only first byte of PDF magic — must NOT match.
+ byte[] head = [0x25];
+ Assert.Null(MimeSniffer.Detect(head));
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ModelsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ModelsTests.cs
new file mode 100644
index 0000000000..9653ced121
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ModelsTests.cs
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 2 / dev plan task 2.1 — public enum shapes.
+///
+public sealed class ModelsTests
+{
+ [Fact]
+ public void AnalysisSection_Default_IsMarkdownPlusFields()
+ {
+ Assert.Equal(AnalysisSection.Markdown | AnalysisSection.Fields, AnalysisSection.Default);
+ }
+
+ [Fact]
+ public void AnalysisSection_None_IsZero()
+ {
+ Assert.Equal((AnalysisSection)0, AnalysisSection.None);
+ }
+
+ [Fact]
+ public void AnalysisSection_FlagsAreDistinctPowersOfTwo()
+ {
+ Assert.Equal(1, (int)AnalysisSection.Markdown);
+ Assert.Equal(2, (int)AnalysisSection.Fields);
+ }
+
+ [Fact]
+ public void DocumentStatus_EnumeratesExpectedValues()
+ {
+ var values = (DocumentStatus[])Enum.GetValues(typeof(DocumentStatus));
+ Assert.Equal(
+ new[] { DocumentStatus.Analyzing, DocumentStatus.Uploading, DocumentStatus.Ready, DocumentStatus.Failed },
+ values);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/OptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/OptionsTests.cs
new file mode 100644
index 0000000000..a95dfd8091
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/OptionsTests.cs
@@ -0,0 +1,85 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 2 / dev plan task 2.2 — Options class argument validation and defaults.
+///
+public sealed class OptionsTests
+{
+ private static readonly Uri s_testEndpoint = new("https://contoso.cognitiveservices.azure.com/");
+
+ [Fact]
+ public void Constructor_ThrowsOnNullEndpoint()
+ {
+ var ex = Assert.Throws(() =>
+ new ContentUnderstandingContextProviderOptions(endpoint: null!, credential: new FakeTokenCredential()));
+ Assert.Equal("endpoint", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullCredential()
+ {
+ var ex = Assert.Throws(() =>
+ new ContentUnderstandingContextProviderOptions(endpoint: s_testEndpoint, credential: null!));
+ Assert.Equal("credential", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_AssignsRequiredFields()
+ {
+ var credential = new FakeTokenCredential();
+ var options = new ContentUnderstandingContextProviderOptions(s_testEndpoint, credential);
+
+ Assert.Same(s_testEndpoint, options.Endpoint);
+ Assert.Same(credential, options.Credential);
+ }
+
+ [Fact]
+ public void Defaults_MatchDesignDoc()
+ {
+ var options = new ContentUnderstandingContextProviderOptions(s_testEndpoint, new FakeTokenCredential());
+
+ Assert.Null(options.AnalyzerId);
+ Assert.Equal(TimeSpan.FromSeconds(5), options.MaxWait);
+ Assert.Equal(AnalysisSection.Default, options.OutputSections);
+ Assert.Null(options.FileSearchConfig);
+ Assert.Null(options.LoggerFactory);
+ }
+
+ [Fact]
+ public void ObjectInitializer_CanSetAllProperties()
+ {
+ var credential = new FakeTokenCredential();
+ var options = new ContentUnderstandingContextProviderOptions
+ {
+ Endpoint = s_testEndpoint,
+ Credential = credential,
+ AnalyzerId = "prebuilt-invoice",
+ MaxWait = TimeSpan.FromSeconds(30),
+ OutputSections = AnalysisSection.Markdown,
+ FileSearchConfig = new FileSearchConfig(),
+ };
+
+ Assert.Same(s_testEndpoint, options.Endpoint);
+ Assert.Same(credential, options.Credential);
+ Assert.Equal("prebuilt-invoice", options.AnalyzerId);
+ Assert.Equal(TimeSpan.FromSeconds(30), options.MaxWait);
+ Assert.Equal(AnalysisSection.Markdown, options.OutputSections);
+ Assert.NotNull(options.FileSearchConfig);
+ }
+
+ // (TimeSpan.Zero is the "no foreground wait" sentinel.)
+ [Fact]
+ public void MaxWait_CanBeSetToZero_ToForceImmediateBackgroundDefer()
+ {
+ var options = new ContentUnderstandingContextProviderOptions(s_testEndpoint, new FakeTokenCredential())
+ {
+ MaxWait = TimeSpan.Zero,
+ };
+
+ Assert.Equal(TimeSpan.Zero, options.MaxWait);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ProviderStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ProviderStateTests.cs
new file mode 100644
index 0000000000..0f8b9369e9
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/ProviderStateTests.cs
@@ -0,0 +1,116 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Concurrent;
+using System.Text.Json;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 2 / dev plan task 2.3 — internal state types are System.Text.Json round-trippable.
+///
+public sealed class ProviderStateTests
+{
+ [Fact]
+ public void DocumentEntry_RoundTripsAllFields()
+ {
+ var entry = new DocumentEntry
+ {
+ DocumentKey = "invoice.pdf",
+ Filename = "invoice.pdf",
+ MediaType = "application/pdf",
+ AnalyzerId = "prebuilt-invoice",
+ Status = DocumentStatus.Ready,
+ AnalyzedAt = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero),
+ AnalysisDuration = TimeSpan.FromSeconds(3.5),
+ UploadDuration = TimeSpan.FromMilliseconds(750),
+ Result = "rendered markdown",
+ Error = null,
+ OperationId = "op-abc-123",
+ };
+
+ var json = JsonSerializer.Serialize(entry);
+ var clone = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(clone);
+ Assert.Equal(entry, clone);
+ }
+
+ [Fact]
+ public void DocumentEntry_PreservesNullableTimestampsAndOptionalFields()
+ {
+ var entry = new DocumentEntry
+ {
+ DocumentKey = "video.mp4",
+ Filename = "video.mp4",
+ MediaType = "video/mp4",
+ AnalyzerId = "prebuilt-videoSearch",
+ Status = DocumentStatus.Analyzing,
+ AnalyzedAt = null,
+ AnalysisDuration = null,
+ UploadDuration = null,
+ Result = null,
+ Error = null,
+ OperationId = "lro-handle",
+ };
+
+ var json = JsonSerializer.Serialize(entry);
+ var clone = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(clone);
+ Assert.Null(clone!.AnalyzedAt);
+ Assert.Null(clone.AnalysisDuration);
+ Assert.Null(clone.UploadDuration);
+ Assert.Null(clone.Result);
+ Assert.Null(clone.Error);
+ Assert.Equal("lro-handle", clone.OperationId);
+ Assert.Equal(DocumentStatus.Analyzing, clone.Status);
+ }
+
+ [Fact]
+ public void ProviderState_RoundTripsDocumentsDictionary()
+ {
+ var state = new ContentUnderstandingProviderState();
+ state.Documents["a.pdf"] = new DocumentEntry { DocumentKey = "a.pdf", Filename = "a.pdf", MediaType = "application/pdf", AnalyzerId = "prebuilt-documentSearch", Status = DocumentStatus.Ready, Result = "A" };
+ state.Documents["b.mp3"] = new DocumentEntry { DocumentKey = "b.mp3", Filename = "b.mp3", MediaType = "audio/mpeg", AnalyzerId = "prebuilt-audioSearch", Status = DocumentStatus.Failed, Error = "boom" };
+
+ var json = JsonSerializer.Serialize(state);
+ var clone = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(clone);
+ Assert.Equal(2, clone!.Documents.Count);
+ Assert.Equal("A", clone.Documents["a.pdf"].Result);
+ Assert.Equal(DocumentStatus.Failed, clone.Documents["b.mp3"].Status);
+ Assert.Equal("boom", clone.Documents["b.mp3"].Error);
+ }
+
+ [Fact]
+ public void ProviderState_RoundTripsInjectedKeys()
+ {
+ var state = new ContentUnderstandingProviderState();
+ state.InjectedKeys.TryAdd("a.pdf", 0);
+ state.InjectedKeys.TryAdd("b.mp3", 0);
+
+ var json = JsonSerializer.Serialize(state);
+ var clone = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(clone);
+ Assert.Equal(2, clone!.InjectedKeys.Count);
+ Assert.Contains("a.pdf", clone.InjectedKeys);
+ Assert.Contains("b.mp3", clone.InjectedKeys);
+ }
+
+ [Fact]
+ public void ProviderState_DocumentsIsConcurrentDictionary()
+ {
+ var state = new ContentUnderstandingProviderState();
+ Assert.IsType>(state.Documents);
+ }
+
+ [Fact]
+ public void ProviderState_InjectedKeysIsConcurrentDictionary()
+ {
+ var state = new ContentUnderstandingProviderState();
+ Assert.IsType>(state.InjectedKeys);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/RendererCoverageGapTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/RendererCoverageGapTests.cs
new file mode 100644
index 0000000000..e76b4b2f04
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/RendererCoverageGapTests.cs
@@ -0,0 +1,112 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Azure.AI.ContentUnderstanding;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Phase 11 — renderer-level coverage gaps:
+/// classifier-category presence/absence, source-metadata propagation, field-value extraction,
+/// and the "no rai_warnings when none present" negative.
+///
+public sealed class RendererCoverageGapTests
+{
+ [Fact]
+ public void Render_UsesProvidedFilename_InSourceFrontMatter()
+ {
+ AnalysisResult result = SharedTestFixtures.MakeInvoiceResult();
+
+ string rendered = AnalysisRenderer.Render(result, "custom_name.pdf", AnalysisSection.Default);
+
+ Assert.Contains("source: custom_name.pdf", rendered, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Render_WithFields_EmitsFieldValuesIntoLlmInput()
+ {
+ AnalysisResult result = SharedTestFixtures.MakeInvoiceResult();
+
+ string rendered = AnalysisRenderer.Render(result, "invoice.pdf", AnalysisSection.Default);
+
+ Assert.Contains("fields:", rendered, StringComparison.Ordinal);
+ Assert.Contains("VendorName", rendered, StringComparison.Ordinal);
+ Assert.Contains("CONTOSO LTD.", rendered, StringComparison.Ordinal);
+ Assert.Contains("TotalDue", rendered, StringComparison.Ordinal);
+ Assert.Contains("$610.00", rendered, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Render_NoWarnings_OmitsRaiWarningsKey()
+ {
+ AnalysisResult result = SharedTestFixtures.MakeInvoiceResult();
+
+ string rendered = AnalysisRenderer.Render(result, "invoice.pdf", AnalysisSection.Default);
+
+ Assert.DoesNotContain("rai_warnings", rendered, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Render_NoCategory_OmitsCategoryFrontMatterKey()
+ {
+ AnalysisResult result = SharedTestFixtures.MakeInvoiceResult();
+
+ string rendered = AnalysisRenderer.Render(result, "invoice.pdf", AnalysisSection.Default);
+
+ Assert.DoesNotContain("category:", rendered, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Render_DocumentWithCategory_EmitsCategoryFrontMatterKey()
+ {
+ DocumentContent content = ContentUnderstandingModelFactory.DocumentContent(
+ mimeType: "application/pdf",
+ analyzerId: null,
+ category: "Legal Contract",
+ path: null,
+ markdown: "Contract body text here.",
+ fields: null,
+ startPageNumber: 1,
+ endPageNumber: 1);
+ AnalysisResult result = ContentUnderstandingModelFactory.AnalysisResult(contents: [content]);
+
+ string rendered = AnalysisRenderer.Render(result, "contract.pdf", AnalysisSection.Markdown);
+
+ Assert.Contains("category:", rendered, StringComparison.Ordinal);
+ Assert.Contains("Legal Contract", rendered, StringComparison.Ordinal);
+ }
+
+ // (per-segment category attribution: each block must carry its own category alongside its markdown body.)
+ [Fact]
+ public void Render_MultiSegmentVideo_AttachesPerSegmentCategoryToCorrectBlock()
+ {
+ AudioVisualContent seg1 = ContentUnderstandingModelFactory.AudioVisualContent(
+ mimeType: "video/mp4",
+ analyzerId: null,
+ category: "ProductDemo",
+ path: null,
+ markdown: "Opening scene with product showcase.",
+ fields: null,
+ startTimeMsValue: 0,
+ endTimeMsValue: 30_000);
+ AudioVisualContent seg2 = ContentUnderstandingModelFactory.AudioVisualContent(
+ mimeType: "video/mp4",
+ analyzerId: null,
+ category: "Testimonial",
+ path: null,
+ markdown: "Customer testimonial segment.",
+ fields: null,
+ startTimeMsValue: 30_000,
+ endTimeMsValue: 60_000);
+ AnalysisResult result = ContentUnderstandingModelFactory.AnalysisResult(contents: [seg1, seg2]);
+
+ string rendered = AnalysisRenderer.Render(result, "promo.mp4", AnalysisSection.Markdown);
+
+ string[] blocks = rendered.Split(["*****"], StringSplitOptions.None);
+ Assert.Equal(2, blocks.Length);
+ Assert.Contains("Opening scene with product showcase.", blocks[0], StringComparison.Ordinal);
+ Assert.Contains("ProductDemo", blocks[0], StringComparison.Ordinal);
+ Assert.Contains("Customer testimonial segment.", blocks[1], StringComparison.Ordinal);
+ Assert.Contains("Testimonial", blocks[1], StringComparison.Ordinal);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/CountingClientFactory.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/CountingClientFactory.cs
new file mode 100644
index 0000000000..7bae82ea75
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/CountingClientFactory.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using Azure.AI.ContentUnderstanding;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Counts how many times is invoked. Returns the same
+/// instance every time so concurrent callers can be
+/// distinguished from accidental re-construction.
+///
+internal sealed class CountingClientFactory : IContentUnderstandingClientFactory
+{
+ private readonly ContentUnderstandingClient _client;
+ private int _count;
+
+ public CountingClientFactory()
+ {
+ // Real client; constructed lazily by the provider — never makes a network call during
+ // the provider's lazy-init test path because the analysis itself is overridden via
+ // AnalyzeOverride.
+ this._client = new ContentUnderstandingClient(
+ new Uri("https://contoso.cognitiveservices.azure.com/"),
+ new FakeTokenCredential());
+ }
+
+ public int CallCount => Volatile.Read(ref this._count);
+
+ public ContentUnderstandingClient Create()
+ {
+ Interlocked.Increment(ref this._count);
+ return this._client;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeAITool.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeAITool.cs
new file mode 100644
index 0000000000..5f14181f83
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeAITool.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Minimal stand-in for Phase 9 tests. Carries a name so assertions can
+/// verify the caller's file_search tool is forwarded into AIContext.Tools.
+///
+internal sealed class FakeAITool : AITool
+{
+ public FakeAITool(string name = "file_search")
+ {
+ this._name = name;
+ }
+
+ private readonly string _name;
+
+ public override string Name => this._name;
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeAnalyzer.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeAnalyzer.cs
new file mode 100644
index 0000000000..b2e93d0bf3
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeAnalyzer.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Returns canned s keyed on the detected filename. Counts how
+/// many times the analyze pipeline was invoked so unsupported-attachment / no-call assertions
+/// can be made. Pair with when a test needs to drive the cross-turn
+/// resume path.
+///
+internal sealed class FakeAnalyzer
+{
+ private readonly Dictionary> _byFilename = new(StringComparer.Ordinal);
+
+ public int CallCount { get; private set; }
+
+ public List<(string Filename, string AnalyzerId)> Calls { get; } = new();
+
+ /// Pin a fixed outcome to the given filename.
+ public FakeAnalyzer Returns(string filename, AnalysisOutcome outcome)
+ {
+ this._byFilename[filename] = _ => outcome;
+ return this;
+ }
+
+ /// Factory variant so each invocation can synthesize a fresh outcome.
+ public FakeAnalyzer Returns(string filename, Func factory)
+ {
+ this._byFilename[filename] = factory;
+ return this;
+ }
+
+ public Task AnalyzeAsync(
+ DetectedAttachment attachment,
+ string analyzerId,
+ TimeSpan maxWait,
+ CancellationToken cancellationToken)
+ {
+ _ = maxWait;
+ _ = cancellationToken;
+
+ this.CallCount++;
+ this.Calls.Add((attachment.Filename, analyzerId));
+
+ if (!this._byFilename.TryGetValue(attachment.Filename, out Func? factory))
+ {
+ throw new InvalidOperationException(
+ $"FakeAnalyzer was not configured for filename '{attachment.Filename}'.");
+ }
+
+ return Task.FromResult(factory(attachment));
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeFileSearchBackend.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeFileSearchBackend.cs
new file mode 100644
index 0000000000..c0221e9cfa
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeFileSearchBackend.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Fake for Phase 9 tests. Records every upload + delete call,
+/// optionally simulates timeouts or hard failures, and hands out incrementing fake file ids.
+///
+internal sealed class FakeFileSearchBackend : FileSearchBackend
+{
+ private int _fileIdCounter;
+
+ public ConcurrentBag UploadCalls { get; } = new();
+
+ public ConcurrentBag DeleteCalls { get; } = new();
+
+ /// When set, the next waits for this task before returning.
+ public Func>? UploadHandler { get; set; }
+
+ /// When set, awaits this task before completing.
+ public Func? DeleteHandler { get; set; }
+
+ public override async Task UploadAsync(
+ string vectorStoreId,
+ string filename,
+ string payload,
+ CancellationToken cancellationToken)
+ {
+ UploadCall call = new(vectorStoreId, filename, payload);
+ this.UploadCalls.Add(call);
+
+ if (this.UploadHandler is not null)
+ {
+ return await this.UploadHandler(call, cancellationToken).ConfigureAwait(false);
+ }
+
+ int next = Interlocked.Increment(ref this._fileIdCounter);
+ return $"file-{next:D4}";
+ }
+
+ public override async Task DeleteAsync(string fileId, CancellationToken cancellationToken)
+ {
+ this.DeleteCalls.Add(fileId);
+ if (this.DeleteHandler is not null)
+ {
+ await this.DeleteHandler(fileId, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ internal sealed record UploadCall(string VectorStoreId, string Filename, string Payload);
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeResumer.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeResumer.cs
new file mode 100644
index 0000000000..b9248305ee
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeResumer.cs
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Returns canned s keyed on the in-flight CU operation id.
+/// Drives in the same way
+/// drives AnalyzeOverride.
+///
+internal sealed class FakeResumer
+{
+ private readonly Dictionary> _byOperationId = new(StringComparer.Ordinal);
+
+ public int CallCount { get; private set; }
+
+ public List<(string OperationId, string AnalyzerId)> Calls { get; } = new();
+
+ public FakeResumer Returns(string operationId, AnalysisOutcome outcome)
+ {
+ this._byOperationId[operationId] = () => outcome;
+ return this;
+ }
+
+ public FakeResumer Returns(string operationId, Func factory)
+ {
+ this._byOperationId[operationId] = factory;
+ return this;
+ }
+
+ public Task ResumeAsync(
+ string operationId,
+ string rehydrationTokenJson,
+ string analyzerId,
+ TimeSpan maxWait,
+ CancellationToken cancellationToken)
+ {
+ _ = rehydrationTokenJson;
+ _ = maxWait;
+ _ = cancellationToken;
+
+ this.CallCount++;
+ this.Calls.Add((operationId, analyzerId));
+
+ if (!this._byOperationId.TryGetValue(operationId, out Func? factory))
+ {
+ throw new InvalidOperationException(
+ $"FakeResumer was not configured for operationId '{operationId}'.");
+ }
+
+ return Task.FromResult(factory());
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeTokenCredential.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeTokenCredential.cs
new file mode 100644
index 0000000000..da35766a9b
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/FakeTokenCredential.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.Core;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Non-network test double for . The constructor-validation tests
+/// only need a non-null reference, never an actual token request.
+///
+internal sealed class FakeTokenCredential : TokenCredential
+{
+ public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ => throw new NotSupportedException("FakeTokenCredential is for argument-validation tests only.");
+
+ public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ => throw new NotSupportedException("FakeTokenCredential is for argument-validation tests only.");
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/SharedTestFixtures.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/SharedTestFixtures.cs
new file mode 100644
index 0000000000..2d284d0eb0
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests/TestDoubles/SharedTestFixtures.cs
@@ -0,0 +1,109 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.AI.ContentUnderstanding;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AzureAI.ContentUnderstanding.UnitTests;
+
+///
+/// Shared fixtures for ContentUnderstandingContextProvider unit tests across phases. Phase 5
+/// originally inlined these as private nested helpers; Phase 6 lifted them so multiple test
+/// files (Phase 5 happy path, Phase 6 background continuation, Phase 7 tools, ...) can share.
+///
+internal static class SharedTestFixtures
+{
+ public static readonly Uri TestEndpoint = new("https://contoso.cognitiveservices.azure.com/");
+
+ public static byte[] LoadFixturePdf()
+ {
+ // Real %PDF- header bytes so DataContent's content-type detection / our MIME sniff are happy.
+ return new byte[]
+ {
+ 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34, 0x0A, 0x25, 0xE2, 0xE3, 0xCF, 0xD3, 0x0A,
+ };
+ }
+
+ public static AnalysisResult MakeInvoiceResult()
+ {
+ Dictionary fields = new(StringComparer.Ordinal)
+ {
+ ["VendorName"] = ContentUnderstandingModelFactory.ContentStringField(value: "CONTOSO LTD."),
+ ["TotalDue"] = ContentUnderstandingModelFactory.ContentStringField(value: "$610.00"),
+ };
+ DocumentContent content = ContentUnderstandingModelFactory.DocumentContent(
+ mimeType: "application/pdf",
+ markdown: "CONTOSO LTD.\n\n# INVOICE\n\nTotal due: $610.00",
+ fields: fields,
+ startPageNumber: 1,
+ endPageNumber: 1);
+ return ContentUnderstandingModelFactory.AnalysisResult(contents: [content]);
+ }
+
+ ///
+ /// Synthesizes an shaped like the long-form audio/video output
+ /// returned by prebuilt-videoSearch: a single result whose Contents list holds
+ /// N blocks, each covering 30s, with distinct markdown.
+ ///
+ ///
+ /// Mirrors the SDK contract verified in Phase 8 analysis: CU returns one AnalysisResult
+ /// with multiple AudioVisualContent entries (not multiple results). The renderer in
+ /// emits timeRange: only when avCount > 1.
+ ///
+ public static AnalysisResult MakeMultiSegmentVideoResult(int segmentCount, int segmentDurationSec = 30)
+ {
+ AudioVisualContent[] segments = new AudioVisualContent[segmentCount];
+ for (int i = 0; i < segmentCount; i++)
+ {
+ long startMs = (long)i * segmentDurationSec * 1000L;
+ long endMs = (long)(i + 1) * segmentDurationSec * 1000L;
+ segments[i] = ContentUnderstandingModelFactory.AudioVisualContent(
+ mimeType: "video/mp4",
+ markdown: $"## Segment {i}\n\nNarration for segment {i}.",
+ startTimeMsValue: startMs,
+ endTimeMsValue: endMs);
+ }
+ return ContentUnderstandingModelFactory.AnalysisResult(contents: segments);
+ }
+}
+
+/// An implementation that holds only the inherited StateBag.
+internal sealed class AgentSessionFake : AgentSession
+{
+}
+
+///
+/// A throw-only ; the provider's
+/// constructor requires a non-null agent reference but never calls into it for unit tests.
+///
+internal sealed class TestAIAgentStub : AIAgent
+{
+ protected override Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default) => throw new NotSupportedException();
+
+ protected override IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default) => throw new NotSupportedException();
+
+ protected override ValueTask SerializeSessionCoreAsync(
+ AgentSession session,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default) => throw new NotSupportedException();
+
+ protected override ValueTask DeserializeSessionCoreAsync(
+ JsonElement serializedState,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default) => throw new NotSupportedException();
+
+ protected override ValueTask CreateSessionCoreAsync(
+ CancellationToken cancellationToken = default) => throw new NotSupportedException();
+}
| | |