diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 00a1882018..69c5698a88 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -152,6 +152,7 @@ + @@ -173,6 +174,7 @@ + diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Agent_OpenAI_Step06_CodeInterpreterFileDownload.csproj b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Agent_OpenAI_Step06_CodeInterpreterFileDownload.csproj new file mode 100644 index 0000000000..06380e8016 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Agent_OpenAI_Step06_CodeInterpreterFileDownload.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Program.cs new file mode 100644 index 0000000000..c01ff4304e --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Program.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to download files generated by Code Interpreter using the Containers API. +// Code Interpreter generates files inside containers (cfile_ / cntr_ IDs) which cannot be +// downloaded via the standard Files API. Use ContainerClient instead. + +#pragma warning disable OPENAI001 + +using System.ClientModel; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Containers; +using OpenAI.Responses; + +string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); +string model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini"; + +var openAIClient = new OpenAIClient(new ApiKeyCredential(apiKey)); + +// Create an agent with Code Interpreter tool enabled +AIAgent agent = openAIClient + .GetResponsesClient() + .AsAIAgent( + model: model, + instructions: "You are a helpful assistant that can generate files using code.", + name: "CodeInterpreterAgent", + tools: [new HostedCodeInterpreterTool()]); + +// Ask the agent to generate a file +AgentResponse response = await agent.RunAsync( + "Create a CSV file with the multiplication times tables from 1 to 12. Include headers."); + +// Display the text response +foreach (TextContent textContent in response.Messages.SelectMany(x => x.Contents).OfType()) +{ + Console.WriteLine(textContent.Text); +} + +// Extract container file citations from response annotations and download +ContainerClient containerClient = openAIClient.GetContainerClient(); + +HashSet downloadedFiles = []; +bool foundContainerFiles = false; + +foreach (AIContent content in response.Messages.SelectMany(x => x.Contents)) +{ + if (content.Annotations is null) + { + continue; + } + + foreach (AIAnnotation annotation in content.Annotations) + { + // Container files from Code Interpreter have ContainerFileCitationMessageAnnotation as raw representation + if (annotation is CitationAnnotation citation + && citation.RawRepresentation is ContainerFileCitationMessageAnnotation containerCitation) + { + foundContainerFiles = true; + + // Deduplicate by container+file ID in case the same file is cited multiple times + string key = $"{containerCitation.ContainerId}/{containerCitation.FileId}"; + if (!downloadedFiles.Add(key)) + { + continue; + } + + Console.WriteLine($"\nDownloading container file: {containerCitation.Filename}"); + Console.WriteLine($" Container ID: {containerCitation.ContainerId}"); + Console.WriteLine($" File ID: {containerCitation.FileId}"); + + BinaryData fileData = await containerClient.DownloadContainerFileAsync( + containerCitation.ContainerId, + containerCitation.FileId); + + // Sanitize filename to prevent path traversal + string safeFilename = Path.GetFileName(containerCitation.Filename); + string outputPath = Path.Combine(Directory.GetCurrentDirectory(), safeFilename); + await File.WriteAllBytesAsync(outputPath, fileData.ToArray()); + Console.WriteLine($" Saved to: {outputPath}"); + } + } +} + +if (!foundContainerFiles) +{ + Console.WriteLine("\nNo container file citations found in the response."); + Console.WriteLine("The model may not have generated a downloadable file for this prompt."); +} diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/README.md b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/README.md new file mode 100644 index 0000000000..4ba457d0f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/README.md @@ -0,0 +1,51 @@ +# Code Interpreter File Download (OpenAI) + +This sample demonstrates how to download files generated by Code Interpreter when using the OpenAI Responses API. + +## What this sample demonstrates + +- Creating an agent with Code Interpreter tool using `ResponsesClient.AsAIAgent()` +- Generating files through Code Interpreter (e.g., CSV, Excel, images) +- Extracting container file citations from agent response annotations +- Downloading container files using the `ContainerClient` API + +## Container files vs regular files + +When Code Interpreter generates a file, the file is stored inside a **container** with a `cntr_` prefixed ID. The file itself gets a `cfile_` prefixed ID. + +These container files **cannot** be downloaded using the standard Files API (`GetOpenAIFileClient`), which returns 404 for `cfile_` IDs. Instead, you must use the **Containers API** (`GetContainerClient`) to download them: + +```csharp +// ❌ This does NOT work for container files +var filesClient = openAIClient.GetOpenAIFileClient(); +await filesClient.DownloadFileAsync("cfile_..."); // Returns 404 + +// ✅ Use ContainerClient instead +var containerClient = openAIClient.GetContainerClient(); +await containerClient.DownloadContainerFileAsync("cntr_...", "cfile_..."); +``` + +The container ID and file ID are available from the `ContainerFileCitationMessageAnnotation` annotation in the response, accessible via `CitationAnnotation.RawRepresentation`. + +## Prerequisites + +- .NET 10 SDK or later +- OpenAI API key with access to a model that supports Code Interpreter + +Set the following environment variables: + +```powershell +$env:OPENAI_API_KEY="sk-..." +$env:OPENAI_CHAT_MODEL_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +```powershell +dotnet run +``` + +## See also + +- [Code Interpreter File Download with Foundry](../../../02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/) — same scenario using Microsoft Foundry +- [Code Interpreter](../../../02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/) — Code Interpreter without file download diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/README.md b/dotnet/samples/02-agents/AgentWithOpenAI/README.md index 74a44600bf..78955a72af 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/README.md +++ b/dotnet/samples/02-agents/AgentWithOpenAI/README.md @@ -14,4 +14,5 @@ Agent Framework provides additional support to allow OpenAI developers to use th |[Using Reasoning Capabilities](./Agent_OpenAI_Step02_Reasoning/)|This sample demonstrates how to create an AI agent with reasoning capabilities using OpenAI's reasoning models and response types.| |[Creating an Agent from a ChatClient](./Agent_OpenAI_Step03_CreateFromChatClient/)|This sample demonstrates how to create an AI agent directly from an OpenAI.Chat.ChatClient instance using OpenAIChatClientAgent.| |[Creating an Agent from an OpenAIResponseClient](./Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/)|This sample demonstrates how to create an AI agent directly from an OpenAI.Responses.OpenAIResponseClient instance using OpenAIResponseClientAgent.| -|[Managing Conversation State](./Agent_OpenAI_Step05_Conversation/)|This sample demonstrates how to maintain conversation state across multiple turns using the AgentSession for context continuity.| \ No newline at end of file +|[Managing Conversation State](./Agent_OpenAI_Step05_Conversation/)|This sample demonstrates how to maintain conversation state across multiple turns using the AgentSession for context continuity.| +|[Code Interpreter File Download](./Agent_OpenAI_Step06_CodeInterpreterFileDownload/)|This sample demonstrates how to download files generated by Code Interpreter using the Containers API (`cfile_`/`cntr_` IDs).| \ No newline at end of file diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj new file mode 100644 index 0000000000..129c9026a2 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Program.cs new file mode 100644 index 0000000000..79fac0d5d4 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Program.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to download files generated by Code Interpreter using Microsoft Foundry. +// Code Interpreter generates files inside containers (cfile_ / cntr_ IDs) which cannot be +// downloaded via the standard Files API. Use ContainerClient from the project's OpenAI client instead. + +#pragma warning disable OPENAI001 + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create an agent with Code Interpreter tool enabled +AIAgent agent = aiProjectClient.AsAIAgent( + deploymentName, + instructions: "You are a helpful assistant that can generate files using code.", + name: "CodeInterpreterAgent", + tools: [new HostedCodeInterpreterTool()]); + +// Ask the agent to generate a file +AgentResponse response = await agent.RunAsync( + "Create a CSV file with the multiplication times tables from 1 to 12. Include headers."); + +// Display the text response +foreach (TextContent textContent in response.Messages.SelectMany(x => x.Contents).OfType()) +{ + Console.WriteLine(textContent.Text); +} + +// Extract container file citations from response annotations and download. +// AIProjectClient.GetProjectOpenAIClient() returns a ProjectOpenAIClient (inherits from OpenAI.OpenAIClient) +// which supports GetContainerClient(), unlike AzureOpenAIClient which does not. +var containerClient = aiProjectClient.GetProjectOpenAIClient().GetContainerClient(); + +HashSet downloadedFiles = []; +bool foundContainerFiles = false; + +foreach (AIContent content in response.Messages.SelectMany(x => x.Contents)) +{ + if (content.Annotations is null) + { + continue; + } + + foreach (AIAnnotation annotation in content.Annotations) + { + // Container files from Code Interpreter have ContainerFileCitationMessageAnnotation as raw representation + if (annotation is CitationAnnotation citation + && citation.RawRepresentation is ContainerFileCitationMessageAnnotation containerCitation) + { + foundContainerFiles = true; + + // Deduplicate by container+file ID in case the same file is cited multiple times + string key = $"{containerCitation.ContainerId}/{containerCitation.FileId}"; + if (!downloadedFiles.Add(key)) + { + continue; + } + + Console.WriteLine($"\nDownloading container file: {containerCitation.Filename}"); + Console.WriteLine($" Container ID: {containerCitation.ContainerId}"); + Console.WriteLine($" File ID: {containerCitation.FileId}"); + + BinaryData fileData = await containerClient.DownloadContainerFileAsync( + containerCitation.ContainerId, + containerCitation.FileId); + + // Sanitize filename to prevent path traversal + string safeFilename = Path.GetFileName(containerCitation.Filename); + string outputPath = Path.Combine(Directory.GetCurrentDirectory(), safeFilename); + await File.WriteAllBytesAsync(outputPath, fileData.ToArray()); + Console.WriteLine($" Saved to: {outputPath}"); + } + } +} + +if (!foundContainerFiles) +{ + Console.WriteLine("\nNo container file citations found in the response."); + Console.WriteLine("The model may not have generated a downloadable file for this prompt."); +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/README.md new file mode 100644 index 0000000000..4d50b98ca8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/README.md @@ -0,0 +1,56 @@ +# Code Interpreter File Download (Microsoft Foundry) + +This sample demonstrates how to download files generated by Code Interpreter when using Microsoft Foundry. + +## What this sample demonstrates + +- Creating an agent with Code Interpreter tool using `AIProjectClient.AsAIAgent()` +- Generating files through Code Interpreter (e.g., CSV, Excel, images) +- Extracting container file citations from agent response annotations +- Downloading container files using the `ContainerClient` via `AIProjectClient.GetProjectOpenAIClient()` + +## Container files vs regular files + +When Code Interpreter generates a file, the file is stored inside a **container** with a `cntr_` prefixed ID. The file itself gets a `cfile_` prefixed ID. + +These container files **cannot** be downloaded using the standard Files API (`GetOpenAIFileClient`), which returns 404 for `cfile_` IDs. Instead, you must use the **Containers API** to download them. + +### Getting the ContainerClient with Foundry + +`AzureOpenAIClient.GetContainerClient()` is not supported and throws `InvalidOperationException`. Instead, use the project's OpenAI client which inherits directly from `OpenAI.OpenAIClient`: + +```csharp +// ❌ AzureOpenAIClient does not support ContainerClient +var azureClient = new AzureOpenAIClient(endpoint, credential); +azureClient.GetContainerClient(); // Throws InvalidOperationException + +// ✅ Use AIProjectClient's project OpenAI client +var containerClient = aiProjectClient.GetProjectOpenAIClient().GetContainerClient(); +await containerClient.DownloadContainerFileAsync("cntr_...", "cfile_..."); +``` + +The container ID and file ID are available from the `ContainerFileCitationMessageAnnotation` annotation in the response, accessible via `CitationAnnotation.RawRepresentation`. + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +```powershell +dotnet run +``` + +## See also + +- [Code Interpreter File Download with OpenAI](../../../02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/) — same scenario using Public OpenAI +- [Code Interpreter](../Agent_Step14_CodeInterpreter/) — Code Interpreter without file download diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/README.md index c65cb24acd..74160ec2ec 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/README.md @@ -72,6 +72,7 @@ Some samples require extra tool-specific environment variables. See each sample | [Web search](./Agent_Step21_WebSearch/) | Web search tool | | [Memory search](./Agent_Step22_MemorySearch/) | Memory search tool | | [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport | +| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter | ## Running the samples diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutorResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutorResult.cs index 99d2e29f50..4bf2a12500 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutorResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutorResult.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; + namespace Microsoft.Agents.AI.Workflows.Declarative.Kit; /// @@ -25,6 +27,11 @@ public sealed record class ActionExecutorResult internal static ActionExecutorResult ThrowIfNot(object? message) { + if (message is PortableValue portableValue && portableValue.IsType(out ActionExecutorResult? unwrapped)) + { + return unwrapped; + } + if (message is not ActionExecutorResult executorMessage) { throw new DeclarativeActionException($"Unexpected message type: {message?.GetType().Name ?? "(null)"} (Expected: {nameof(ActionExecutorResult)})"); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 24653af0f2..86efa6ec43 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -27,9 +27,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA public static string Resume(string id) => $"{id}_{nameof(Resume)}"; } - public static bool RequiresInput(object? message) => message is ExternalInputRequest; + public static bool RequiresInput(object? message) => + message is ExternalInputRequest || (message is PortableValue pv && pv.IsType(out ExternalInputRequest? _)); - public static bool RequiresNothing(object? message) => message is ActionExecutorResult; + public static bool RequiresNothing(object? message) => + message is ActionExecutorResult || (message is PortableValue pv && pv.IsType(out ActionExecutorResult? _)); private AzureAgentUsage AgentUsage => Throw.IfNull(this.Model.Agent, $"{nameof(this.Model)}.{nameof(this.Model.Agent)}"); private AzureAgentInput? AgentInput => this.Model.Input; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs index b1d9a44269..7540556f64 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs @@ -46,12 +46,14 @@ internal sealed class InvokeMcpToolExecutor( /// /// Determines if the message indicates external input is required. /// - public static bool RequiresInput(object? message) => message is ExternalInputRequest; + public static bool RequiresInput(object? message) => + message is ExternalInputRequest || (message is PortableValue pv && pv.IsType(out ExternalInputRequest? _)); /// /// Determines if the message indicates no external input is required. /// - public static bool RequiresNothing(object? message) => message is ActionExecutorResult; + public static bool RequiresNothing(object? message) => + message is ActionExecutorResult || (message is PortableValue pv && pv.IsType(out ActionExecutorResult? _)); /// protected override bool EmitResultEvent => false; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/PortableValuePredicateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/PortableValuePredicateTests.cs new file mode 100644 index 0000000000..4ed50afb5a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/PortableValuePredicateTests.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft. All rights reserved. + +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Kit; + +/// +/// Tests that edge predicates correctly handle PortableValue-wrapped messages, +/// which occur after checkpoint restore (JSON round-trip). +/// +public sealed class PortableValuePredicateTests +{ + #region ActionExecutorResult.ThrowIfNot + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithDirectActionExecutorResult_ReturnsResult() + { + // Arrange + ActionExecutorResult result = new("test-executor"); + + // Act + ActionExecutorResult actual = ActionExecutorResult.ThrowIfNot(result); + + // Assert + actual.Should().BeSameAs(result); + } + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithPortableValueWrappedActionExecutorResult_Unwraps() + { + // Arrange + ActionExecutorResult result = new("test-executor"); + PortableValue wrapped = new(result); + + // Act + ActionExecutorResult actual = ActionExecutorResult.ThrowIfNot(wrapped); + + // Assert + actual.ExecutorId.Should().Be("test-executor"); + } + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithNonActionExecutorResult_Throws() + { + // Arrange + object message = "not an ActionExecutorResult"; + + // Act & Assert + Assert.Throws(() => ActionExecutorResult.ThrowIfNot(message)); + } + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithNull_Throws() + { + // Act & Assert + Assert.Throws(() => ActionExecutorResult.ThrowIfNot(null)); + } + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithPortableValueWrappedNonResult_Throws() + { + // Arrange + PortableValue wrapped = new("not an ActionExecutorResult"); + + // Act & Assert + Assert.Throws(() => ActionExecutorResult.ThrowIfNot(wrapped)); + } + + #endregion + + #region InvokeAzureAgentExecutor Predicates + + [Fact] + public void InvokeAzureAgentExecutor_RequiresInput_WithDirectExternalInputRequest_ReturnsTrue() + { + // Arrange + ExternalInputRequest request = new("test prompt"); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresInput(request).Should().BeTrue(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresInput_WithPortableValueWrappedRequest_ReturnsTrue() + { + // Arrange + ExternalInputRequest request = new("test prompt"); + PortableValue wrapped = new(request); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresInput(wrapped).Should().BeTrue(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresInput_WithActionExecutorResult_ReturnsFalse() + { + // Arrange + ActionExecutorResult result = new("test"); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresInput(result).Should().BeFalse(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresNothing_WithDirectActionExecutorResult_ReturnsTrue() + { + // Arrange + ActionExecutorResult result = new("test"); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresNothing(result).Should().BeTrue(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresNothing_WithPortableValueWrappedResult_ReturnsTrue() + { + // Arrange + ActionExecutorResult result = new("test"); + PortableValue wrapped = new(result); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresNothing(wrapped).Should().BeTrue(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresNothing_WithExternalInputRequest_ReturnsFalse() + { + // Arrange + ExternalInputRequest request = new("test prompt"); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresNothing(request).Should().BeFalse(); + } + + #endregion + + #region InvokeMcpToolExecutor Predicates + + [Fact] + public void InvokeMcpToolExecutor_RequiresInput_WithPortableValueWrappedRequest_ReturnsTrue() + { + // Arrange + ExternalInputRequest request = new("test prompt"); + PortableValue wrapped = new(request); + + // Act & Assert + InvokeMcpToolExecutor.RequiresInput(wrapped).Should().BeTrue(); + } + + [Fact] + public void InvokeMcpToolExecutor_RequiresNothing_WithPortableValueWrappedResult_ReturnsTrue() + { + // Arrange + ActionExecutorResult result = new("test"); + PortableValue wrapped = new(result); + + // Act & Assert + InvokeMcpToolExecutor.RequiresNothing(wrapped).Should().BeTrue(); + } + + #endregion + + #region QuestionExecutor.IsComplete + + [Fact] + public void QuestionExecutor_IsComplete_WithPortableValueWrappedResult_NullResult_ReturnsTrue() + { + // Arrange - result with null Result property means "complete" + ActionExecutorResult result = new("test", result: null); + PortableValue wrapped = new(result); + + // Act & Assert + QuestionExecutor.IsComplete(wrapped).Should().BeTrue(); + } + + [Fact] + public void QuestionExecutor_IsComplete_WithPortableValueWrappedResult_NonNullResult_ReturnsFalse() + { + // Arrange - result with non-null Result property means "not complete" + ActionExecutorResult result = new("test", result: true); + PortableValue wrapped = new(result); + + // Act & Assert + QuestionExecutor.IsComplete(wrapped).Should().BeFalse(); + } + + #endregion +}