mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
d30cd927b0
* feat: Add Foundry Toolbox (MCP) support to AgentFrameworkResponseHandler Adds support for Foundry Toolsets MCP proxy integration in the hosted agent response handler. Toolsets connect at startup via IHostedService, gating the readiness probe per spec §3.1. MCP tools are injected into every request's ChatOptions and OAuth consent errors (-32006) are intercepted and surfaced as mcp_approval_request + incomplete SSE events. New files: - FoundryToolboxOptions.cs: configuration POCO for toolset names and API version - FoundryToolboxBearerTokenHandler.cs: DelegatingHandler with Azure Bearer token auth, Foundry-Features header injection, and 3x exponential backoff on 429/5xx - McpConsentContext.cs: AsyncLocal-based per-request consent state shared between the tool wrapper and the response handler - ConsentAwareMcpClientTool.cs: AIFunction wrapper that catches -32006 errors and signals consent via shared state and linked CancellationTokenSource - FoundryToolboxService.cs: IHostedService that creates McpClient per toolset at startup and exposes cached tools Modified files: - AgentFrameworkResponseHandler.cs: injects toolbox tools into ChatOptions, sets up linked CTS consent interception, emits mcp_approval_request on -32006 - ServiceCollectionExtensions.cs: adds AddFoundryToolboxes(params string[]) extension - Microsoft.Agents.AI.Foundry.csproj: adds ModelContextProtocol and Azure.Identity dependencies under NETCoreApp condition Sample: - Hosted-Toolbox: minimal hosted agent sample using AddFoundryToolboxes * Rename toolset to toolbox in user-facing API; rename ConsentAwareMcpClientTool to ConsentAwareMcpClientAIFunction * Add HostedMcpToolboxAITool for client-selectable Foundry toolboxes Introduces HostedMcpToolboxAITool, a marker tool subclassing HostedMcpServerTool that rides the OpenAI Responses 'mcp' wire format to let clients request a specific Foundry toolbox per request. - New FoundryAITool.CreateHostedMcpToolbox(name, version?) factory. - FoundryToolboxOptions.StrictMode (default true) rejects unregistered toolboxes; set to false to allow lazy-open on first use. - FoundryToolboxService.GetToolboxToolsAsync(name, version?) resolves cached or lazy-opened MCP tools. - AgentFrameworkResponseHandler parses request.Tools for foundry-toolbox://name[?version=v] markers and injects resolved tools per request, merging with pre-registered ones. - Unit tests for marker parsing and strict-mode resolution. * Bump Azure.AI.Projects to 2.1.0-alpha; add ToolboxRecord/ToolboxVersion factory overloads + tests * Fix PR review issues: retry off-by-one, URI encoding, docs, tests, build - Fix off-by-one in FoundryToolboxBearerTokenHandler retry loop (4 attempts → 3) - URI-encode version parameter in HostedMcpToolboxAITool.BuildAddress - Add XML doc clarifying version pinning is reserved for future use - Add comment clarifying AddHostedService deduplication safety - Fix DevTemporaryTokenCredential expiry to use DateTimeOffset.MaxValue - Fix AgentCard ambiguity in A2AServer sample with using alias - Add 18 new unit tests for retry handler and ReadMcpToolboxMarkers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
768 lines
24 KiB
C#
768 lines
24 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Linq;
|
|
using Azure.AI.AgentServer.Responses;
|
|
using Azure.AI.AgentServer.Responses.Models;
|
|
using Microsoft.Agents.AI.Foundry.Hosting;
|
|
using Microsoft.Extensions.AI;
|
|
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
|
|
|
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
|
|
|
public class InputConverterTests
|
|
{
|
|
[Fact]
|
|
public void ConvertInputToMessages_EmptyRequest_ReturnsEmptyList()
|
|
{
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(Array.Empty<object>());
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Empty(messages);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_UserTextMessage_ReturnsUserMessage()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_001",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_text", text = "Hello, agent!" } }
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Equal(ChatRole.User, messages[0].Role);
|
|
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text == "Hello, agent!");
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_FunctionCallOutput_ReturnsToolMessage()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "function_call_output",
|
|
id = "fc_out_001",
|
|
call_id = "call_123",
|
|
output = "42"
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Equal(ChatRole.Tool, messages[0].Role);
|
|
var funcResult = messages[0].Contents.OfType<FunctionResultContent>().FirstOrDefault();
|
|
Assert.NotNull(funcResult);
|
|
Assert.Equal("call_123", funcResult.CallId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_FunctionToolCall_ReturnsAssistantMessage()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "function_call",
|
|
id = "fc_001",
|
|
call_id = "call_456",
|
|
name = "get_weather",
|
|
arguments = "{\"location\": \"Seattle\"}"
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Equal(ChatRole.Assistant, messages[0].Role);
|
|
var funcCall = messages[0].Contents.OfType<FunctionCallContent>().FirstOrDefault();
|
|
Assert.NotNull(funcCall);
|
|
Assert.Equal("call_456", funcCall.CallId);
|
|
Assert.Equal("get_weather", funcCall.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_MultipleItems_ReturnsAllMessages()
|
|
{
|
|
var input = new object[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_001",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_text", text = "What's the weather?" } }
|
|
},
|
|
new
|
|
{
|
|
type = "function_call",
|
|
id = "fc_001",
|
|
call_id = "call_789",
|
|
name = "get_weather",
|
|
arguments = "{}"
|
|
},
|
|
new
|
|
{
|
|
type = "function_call_output",
|
|
id = "fc_out_001",
|
|
call_id = "call_789",
|
|
output = "Sunny, 72°F"
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Equal(3, messages.Count);
|
|
Assert.Equal(ChatRole.User, messages[0].Role);
|
|
Assert.Equal(ChatRole.Assistant, messages[1].Role);
|
|
Assert.Equal(ChatRole.Tool, messages[2].Role);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertToChatOptions_SetsTemperatureAndTopP()
|
|
{
|
|
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
|
temperature: 0.7,
|
|
topP: 0.9,
|
|
maxOutputTokens: 1000,
|
|
model: "gpt-4o");
|
|
|
|
var options = InputConverter.ConvertToChatOptions(request);
|
|
|
|
Assert.Equal(0.7f, options.Temperature);
|
|
Assert.Equal(0.9f, options.TopP);
|
|
Assert.Equal(1000, options.MaxOutputTokens);
|
|
Assert.Null(options.ModelId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertToChatOptions_NullValues_SetsNulls()
|
|
{
|
|
var request = new CreateResponse();
|
|
|
|
var options = InputConverter.ConvertToChatOptions(request);
|
|
|
|
Assert.Null(options.Temperature);
|
|
Assert.Null(options.TopP);
|
|
Assert.Null(options.MaxOutputTokens);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertOutputItemsToMessages_OutputMessage_ReturnsAssistantMessage()
|
|
{
|
|
var textContent = new MessageContentOutputTextContent(
|
|
"Hello from assistant",
|
|
Array.Empty<Annotation>(),
|
|
Array.Empty<LogProb>());
|
|
var outputMsg = new OutputItemMessage(
|
|
id: "out_001",
|
|
role: MessageRole.Assistant,
|
|
content: [textContent],
|
|
status: MessageStatus.Completed);
|
|
|
|
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Equal(ChatRole.Assistant, messages[0].Role);
|
|
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text == "Hello from assistant");
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertOutputItemsToMessages_FunctionToolCall_ReturnsAssistantMessage()
|
|
{
|
|
var funcCall = new OutputItemFunctionToolCall(
|
|
callId: "call_abc",
|
|
name: "search",
|
|
arguments: "{\"query\": \"test\"}");
|
|
|
|
var messages = InputConverter.ConvertOutputItemsToMessages([funcCall]);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Equal(ChatRole.Assistant, messages[0].Role);
|
|
var content = messages[0].Contents.OfType<FunctionCallContent>().FirstOrDefault();
|
|
Assert.NotNull(content);
|
|
Assert.Equal("call_abc", content.CallId);
|
|
Assert.Equal("search", content.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertOutputItemsToMessages_FunctionToolCallOutputResource_ReturnsToolMessage()
|
|
{
|
|
var funcOutput = new FunctionToolCallOutputResource(
|
|
callId: "call_def",
|
|
output: BinaryData.FromString("result data"));
|
|
|
|
var messages = InputConverter.ConvertOutputItemsToMessages([funcOutput]);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Equal(ChatRole.Tool, messages[0].Role);
|
|
var result = messages[0].Contents.OfType<FunctionResultContent>().FirstOrDefault();
|
|
Assert.NotNull(result);
|
|
Assert.Equal("call_def", result.CallId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertOutputItemsToMessages_ReasoningItem_ReturnsNull()
|
|
{
|
|
var reasoning = AzureAIAgentServerResponsesModelFactory.OutputItemReasoningItem(
|
|
id: "reason_001");
|
|
|
|
var messages = InputConverter.ConvertOutputItemsToMessages([reasoning]);
|
|
|
|
Assert.Empty(messages);
|
|
}
|
|
|
|
// ── Image Content Tests (B-03 through B-06) ──
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_ImageContentWithHttpUrl_ReturnsUriContent()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_image", image_url = "https://example.com/img.png" } }
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Contains(messages[0].Contents, c => c is UriContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_ImageContentWithDataUri_ReturnsDataContent()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_image", image_url = "data:image/png;base64,iVBORw0KGgo=" } }
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Contains(messages[0].Contents, c => c is DataContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_ImageContentWithFileId_ReturnsHostedFileContent()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_image", file_id = "file_abc123" } }
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Contains(messages[0].Contents, c => c is HostedFileContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_ImageContentNoUrlOrFileId_ProducesNoContent()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_image" } }
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Single(messages[0].Contents);
|
|
}
|
|
|
|
// ── File Content Tests (B-07 through B-11) ──
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_FileContentWithUrl_ReturnsUriContent()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_file", file_url = "https://example.com/doc.pdf" } }
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Contains(messages[0].Contents, c => c is UriContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_FileContentWithInlineData_ReturnsDataContent()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_file", file_data = "data:application/pdf;base64,iVBORw0KGgo=" } }
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Contains(messages[0].Contents, c => c is DataContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_FileContentWithFileId_ReturnsHostedFileContent()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_file", file_id = "file_xyz789" } }
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Contains(messages[0].Contents, c => c is HostedFileContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_FileContentWithFilenameOnly_ReturnsFallbackText()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_file", filename = "report.pdf" } }
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("report.pdf"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_FileContentWithNothing_ProducesNoContent()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_file" } }
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Single(messages[0].Contents);
|
|
}
|
|
|
|
// ── Mixed Content / Edge Cases (B-15 through B-18) ──
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_MixedContentInSingleMessage_ReturnsAllContentTypes()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new object[]
|
|
{
|
|
new { type = "input_text", text = "Look at this:" },
|
|
new { type = "input_image", image_url = "https://example.com/img.png" }
|
|
}
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Equal(2, messages[0].Contents.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_EmptyMessageContent_ReturnsFallbackTextContent()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = Array.Empty<object>()
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
var textContent = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
|
|
Assert.Equal(string.Empty, textContent.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertOutputItemsToMessages_OutputMessageRefusal_ReturnsRefusalText()
|
|
{
|
|
var refusal = new MessageContentRefusalContent("I cannot help with that");
|
|
var outputMsg = new OutputItemMessage(
|
|
id: "out_1",
|
|
role: MessageRole.Assistant,
|
|
content: [refusal],
|
|
status: MessageStatus.Completed);
|
|
|
|
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("[Refusal:"));
|
|
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("I cannot help with that"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_ItemReferenceParam_IsSkipped()
|
|
{
|
|
var input = new object[]
|
|
{
|
|
new { type = "item_reference", id = "ref_001" },
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_text", text = "Hello" } }
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
}
|
|
|
|
// ── Role Mapping Tests (C-01 through C-05) ──
|
|
|
|
[Fact]
|
|
public void ConvertInputToMessages_UserRole_ReturnsChatRoleUser()
|
|
{
|
|
var input = new[]
|
|
{
|
|
new
|
|
{
|
|
type = "message",
|
|
id = "msg_1",
|
|
status = "completed",
|
|
role = "user",
|
|
content = new[] { new { type = "input_text", text = "Hi" } }
|
|
}
|
|
};
|
|
|
|
var request = new CreateResponse();
|
|
request.Input = BinaryData.FromObjectAsJson(input);
|
|
|
|
var messages = InputConverter.ConvertInputToMessages(request);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Equal(ChatRole.User, messages[0].Role);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertOutputItemsToMessages_AssistantRole_ReturnsChatRoleAssistant()
|
|
{
|
|
// OutputItemMessage always maps to assistant role
|
|
var textContent = new MessageContentOutputTextContent(
|
|
"Hi", Array.Empty<Annotation>(), Array.Empty<LogProb>());
|
|
var outputMsg = new OutputItemMessage(
|
|
id: "msg_1",
|
|
role: MessageRole.Assistant,
|
|
content: [textContent],
|
|
status: MessageStatus.Completed);
|
|
|
|
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Equal(ChatRole.Assistant, messages[0].Role);
|
|
}
|
|
|
|
// ── History Conversion Edge Cases (D-02 through D-12) ──
|
|
|
|
[Fact]
|
|
public void ConvertOutputItemsToMessages_OutputMessageWithRefusal_ReturnsRefusalText()
|
|
{
|
|
var refusal = new MessageContentRefusalContent("Not allowed");
|
|
var outputMsg = new OutputItemMessage(
|
|
id: "out_1",
|
|
role: MessageRole.Assistant,
|
|
content: [refusal],
|
|
status: MessageStatus.Completed);
|
|
|
|
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
|
|
|
Assert.Single(messages);
|
|
Assert.Equal(ChatRole.Assistant, messages[0].Role);
|
|
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("[Refusal:"));
|
|
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("Not allowed"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertOutputItemsToMessages_OutputMessageWithEmptyContent_ReturnsFallbackText()
|
|
{
|
|
var outputMsg = new OutputItemMessage(
|
|
id: "out_1",
|
|
role: MessageRole.Assistant,
|
|
content: [],
|
|
status: MessageStatus.Completed);
|
|
|
|
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
|
|
|
Assert.Single(messages);
|
|
var textContent = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
|
|
Assert.Equal(string.Empty, textContent.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertOutputItemsToMessages_FunctionToolCallWithMalformedArgs_UsesRawFallback()
|
|
{
|
|
var funcCall = new OutputItemFunctionToolCall(
|
|
callId: "call_1",
|
|
name: "test",
|
|
arguments: "not-json{{{");
|
|
|
|
var messages = InputConverter.ConvertOutputItemsToMessages([funcCall]);
|
|
|
|
Assert.Single(messages);
|
|
var content = messages[0].Contents.OfType<FunctionCallContent>().FirstOrDefault();
|
|
Assert.NotNull(content);
|
|
Assert.NotNull(content.Arguments);
|
|
Assert.True(content.Arguments.ContainsKey("_raw"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertOutputItemsToMessages_UnknownOutputItemType_IsSkipped()
|
|
{
|
|
var messages = InputConverter.ConvertOutputItemsToMessages([]);
|
|
|
|
Assert.Empty(messages);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertToChatOptions_ModelId_NotSetFromRequest()
|
|
{
|
|
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model");
|
|
|
|
var options = InputConverter.ConvertToChatOptions(request);
|
|
|
|
// Model from the request is intentionally NOT propagated — the hosted agent uses its own model.
|
|
Assert.Null(options.ModelId);
|
|
}
|
|
|
|
// ── ReadMcpToolboxMarkers tests ──────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void ReadMcpToolboxMarkers_NullTools_ReturnsEmpty()
|
|
{
|
|
var request = new CreateResponse();
|
|
// Tools defaults to null when not set via JSON deserialization.
|
|
|
|
var markers = InputConverter.ReadMcpToolboxMarkers(request);
|
|
|
|
Assert.Empty(markers);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadMcpToolboxMarkers_McpToolWithToolboxAddress_ReturnsMarker()
|
|
{
|
|
var request = new CreateResponse();
|
|
request.Tools.Add(new MCPTool("test-toolbox")
|
|
{
|
|
ServerUrl = new Uri("foundry-toolbox://my-toolbox")
|
|
});
|
|
|
|
var markers = InputConverter.ReadMcpToolboxMarkers(request);
|
|
|
|
Assert.Single(markers);
|
|
Assert.Equal("my-toolbox", markers[0].Name);
|
|
Assert.Null(markers[0].Version);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadMcpToolboxMarkers_McpToolWithVersionedAddress_ReturnsNameAndVersion()
|
|
{
|
|
var request = new CreateResponse();
|
|
request.Tools.Add(new MCPTool("test-toolbox")
|
|
{
|
|
ServerUrl = new Uri("foundry-toolbox://my-toolbox?version=v3")
|
|
});
|
|
|
|
var markers = InputConverter.ReadMcpToolboxMarkers(request);
|
|
|
|
Assert.Single(markers);
|
|
Assert.Equal("my-toolbox", markers[0].Name);
|
|
Assert.Equal("v3", markers[0].Version);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadMcpToolboxMarkers_McpToolWithNonToolboxUrl_SkipsIt()
|
|
{
|
|
var request = new CreateResponse();
|
|
request.Tools.Add(new MCPTool("external-mcp")
|
|
{
|
|
ServerUrl = new Uri("https://example.com/mcp")
|
|
});
|
|
|
|
var markers = InputConverter.ReadMcpToolboxMarkers(request);
|
|
|
|
Assert.Empty(markers);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadMcpToolboxMarkers_McpToolWithNullServerUrl_SkipsIt()
|
|
{
|
|
var request = new CreateResponse();
|
|
request.Tools.Add(new MCPTool("test") { ServerUrl = null });
|
|
|
|
var markers = InputConverter.ReadMcpToolboxMarkers(request);
|
|
|
|
Assert.Empty(markers);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadMcpToolboxMarkers_MixedTools_ReturnsOnlyToolboxMarkers()
|
|
{
|
|
var request = new CreateResponse();
|
|
request.Tools.Add(new MCPTool("external")
|
|
{
|
|
ServerUrl = new Uri("https://example.com/mcp")
|
|
});
|
|
request.Tools.Add(new MCPTool("toolbox-1")
|
|
{
|
|
ServerUrl = new Uri("foundry-toolbox://box-a")
|
|
});
|
|
request.Tools.Add(new MCPTool("toolbox-2")
|
|
{
|
|
ServerUrl = new Uri("foundry-toolbox://box-b?version=2025-01")
|
|
});
|
|
|
|
var markers = InputConverter.ReadMcpToolboxMarkers(request);
|
|
|
|
Assert.Equal(2, markers.Count);
|
|
Assert.Equal("box-a", markers[0].Name);
|
|
Assert.Null(markers[0].Version);
|
|
Assert.Equal("box-b", markers[1].Name);
|
|
Assert.Equal("2025-01", markers[1].Version);
|
|
}
|
|
}
|