Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InputConverterTests.cs
T
4b5a8478de .NET: Hosting updates to declarative workflows (#5589)
* Make DeclarativeWorkflowExecutor ChatProtocol-compatible for AsAIAgent hosting

Extends the existing DeclarativeWorkflowExecutor<TInput> root executor with
additional ChatProtocol-compatible input routes (string, ChatMessage,
IEnumerable<ChatMessage>, ChatMessage[], TurnToken) so that workflows built
via DeclarativeWorkflowBuilder.Build<TInput>(...) work both for direct
invocation and when hosted via Workflow.AsAIAgent(...).

- Each input message advances the declarative graph immediately; the
  TurnToken that the host sends after the message batch is treated as a
  no-op since the message has already been processed.
- Conversation id resolution now prefers persisted workflow system state,
  then DeclarativeWorkflowOptions.ConversationId, then a newly created
  conversation. This makes multi-turn invocations reuse the prior
  conversation rather than creating a fresh one each turn.
- The separate DeclarativeChatProtocolStartExecutor and
  DeclarativeWorkflowBuilder.BuildChatProtocol overloads introduced
  earlier are removed; callers continue to use Build<TInput>(...).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: use DeclarativeWorkflowContext when reading workflow conversation id

GetWorkflowConversation() requires a DeclarativeWorkflowContext (it calls ReadState which dynamic-casts via the DeclarativeContext helper). The chat-protocol auxiliary handlers receive a BoundWorkflowContext, so calling the extension on the raw IWorkflowContext throws `Invalid workflow context: BoundWorkflowContext`. Use the wrapped declarativeContext that we already constructed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: surface ExecutorFailedEvent as ErrorContent in AsAIAgent response

WorkflowSession.InvokeStageAsync only converted WorkflowErrorEvent into an ErrorContent payload. ExecutorFailedEvent fell through to the default branch which emits an empty AgentResponseUpdate carrying the event in RawRepresentation. OutputConverter then mapped that to a workflow_action item with status=failed and dropped the exception entirely, so callers got status=completed and error=null even when an executor threw.

- WorkflowSession.cs: add ExecutorFailedEvent case mirroring WorkflowErrorEvent. Honors _includeExceptionDetails.

- OutputConverter.cs: when an update carries both a WorkflowEvent in RawRepresentation and non-empty Contents, fall through to content processing so the unwrapped error (or any future content payload from a workflow event) is actually emitted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* improve: walk inner exceptions when surfacing ExecutorFailedEvent

DeclarativeActionExecutor wraps inner exceptions in DeclarativeActionException with a generic `Unhandled workflow failure` message, hiding the real cause. Walk InnerException so the response shows the full chain (e.g. the underlying HTTP 400 / auth error).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Surface declarative SendActivity output as chat content

SendActivityExecutor now emits AgentResponseEvent in addition to
MessageActivityEvent so chat protocols (e.g. AsAIAgent) receive the
formatted activity text. The existing MessageActivityEvent is preserved
for DevUI/observability.

Also extend WorkflowSession.WorkflowOutputEvent handling to accept
AgentResponse payloads, mapping them to their constituent ChatMessages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Persist hosted-agent sessions to disk; fix System.LastMessageText

Adds FileSystemAgentSessionStore that writes the serialized AgentSession JSON

(which already embeds the workflow's in-memory checkpoint manager) to a per-

conversation file under /.checkpoints when running in a Foundry hosted env

or {cwd}/.checkpoints locally. Mirrors the python foundry_hosting._responses

FileCheckpointStorage pattern so multi-turn workflow state survives process

restarts without requiring callers to wire up storage themselves.

AddFoundryResponses now defaults to FileSystemAgentSessionStore.CreateDefault()

instead of InMemoryAgentSessionStore; callers can still override via DI.

Also fixes {System.LastMessageText} resolving empty: DeclarativeWorkflowExecutor

.AdvanceAsync was passing the message rehydrated from CreateMessageAsync to

SetLastMessageAsync, but ResponseItem -> ChatMessage round-trip drops the .Text

extension content. Use the original input ChatMessage (which still has the

user-supplied text) and copy the server-assigned MessageId across when present.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Close multi-modal input parity gaps with python foundry_hosting

InputConverter now mirrors the python _responses.py content handling:

- ComputerScreenshotContent maps to UriContent/HostedFileContent (was dropped).

- Plain TextContent and SummaryTextContent map to MEAI TextContent.

- MessageContentReasoningTextContent maps to MEAI TextReasoningContent.

- input_file with text/* file_data data URIs is decoded inline into

  TextContent with a [File: name] prefix, matching python _convert_file_data

  so {System.LastMessageText} surfaces the file body. Non-text data URIs and

  hosted/url file references preserve filename as AdditionalProperties.

Image/file extraction logic is extracted into shared AppendImageContent and

AppendFileContent helpers used by both the fresh-input and history-replay

switches. Existing 37 InputConverter tests still pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Foundry hosting: round-trip tool-approval (HITL) content as mcp_approval_request/response

Closes the gap where Microsoft.Agents.AI.Foundry.Hosting silently dropped
MEAI ToolApprovalRequestContent/ToolApprovalResponseContent in both
directions. We now serialize them onto the wire as the standard Responses
API mcp_approval_request/mcp_approval_response items with
server_label='agent_framework', and parse the symmetric inbound shapes
back into MEAI content.

Wire format:
- The Responses API only standardizes mcp_approval_* as the approval
  primitive. We declare AF as a virtual MCP server via the server_label
  field, which is honest for AF's server-side tool-call holding pattern.
- The SDK enforces a strict {prefix}_{50hex} wire-id format, so we hash
  the AF RequestId and persist a wireId<->afRequestId mapping in
  AgentSession.StateBag so a later mcp_approval_response can be matched
  back to the originating workflow request.

Coexists with the existing ConsentAwareMcpClientAIFunction flow
(AgentFrameworkResponseHandler.cs) which emits mcp_approval_request from
a side-channel, not via OutputConverter's content switch.

Known follow-up: python (foundry_hosting/_responses.py) has the same
output-side gap (ToolApprovalRequestContent emission). Out of scope here.

Tests: +9 unit tests covering both fresh-input and history-replay shapes,
StateBag mapping resolution, and the non-FunctionCallContent skip path.
Existing 108 converter tests still pass; full suite 370/370.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback for hosted-declarative-dotnet

FileSystemAgentSessionStore reliability/scoping:

- Bound Sanitize() stackalloc at 256 chars, fall back to ArrayPool for longer ids so a long conversationId can no longer crash the hosting process with StackOverflowException.

- Use a Guid-suffixed temp file (\{path}.{guid}.tmp\) so concurrent SaveSessionAsync calls on the same conversation can no longer race on the same temp file. Best-effort temp cleanup on failure.

- Bucket session files by agent.Name when set so two keyed agents that happen to share a conversationId no longer overwrite each other's persisted state. Single-agent / unnamed-agent cases keep the original flat layout (Python parity).

DeclarativeWorkflowExecutor chat-protocol routing:

- ConfigureChatProtocolRoutes uses IsAssignableFrom rather than exact type equality so a broader TInput (object, base interfaces) does not have its inherited inputTransform shadowed by handlers we register here.

- HandleChatMessagesAsync / HandleChatMessageArrayAsync now advance through every message in the batch instead of keeping only the trailing one, so multi-message turns and replayed history are no longer silently truncated. AdvanceAsync gains a finalizeTurn flag so only the last message in the batch sends the result.

Tests:

- New FileSystemAgentSessionStoreTests covering constructor, fresh-session fallback for missing/empty files, root-directory creation, save/get round-trip, agent-Name scoping isolation, long conversationId, invalid-character sanitization, and concurrent-save behavior.

- New InputConverterTests covering AppendFileContent: text/* data URI decode (with and without filename prefix), non-text data URI passthrough, malformed data URI fallback, and filename propagation onto UriContent / HostedFileContent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add tests for remaining PR review feedback (C2, D1, E1)

C2: InputConverter — add 9 tests covering SDK content types that previously
had no coverage:
  - SdkTextContent → TextContent (input + output paths)
  - SummaryTextContent → TextContent (input + output paths)
  - MessageContentReasoningTextContent → TextReasoningContent (input + output)
  - ComputerScreenshotContent (HTTP URL → UriContent, data: URI → DataContent,
    output path → UriContent)

D1: OutputConverter — add 2 tests for the WorkflowEvent + Contents fall-through:
  - WorkflowEvent in RawRepresentation with text Contents must flow through
    the content-processing path (text-delta event emitted).
  - WorkflowEvent + ErrorContent must produce a failed event rather than be
    swallowed by the workflow branch.

E1: SendActivityExecutor — extend CaptureActivityAsync to assert that the
executor emits an AgentResponseEvent carrying the activity text with the
correct ExecutorId and ChatRole.Assistant role.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Defense-in-depth: neutralize dot-segments in Sanitize and cap TryDecodeTextDataUri input size

Addresses claude-opus-4.6 security review on PR #5589:

- FileSystemAgentSessionStore.Sanitize now replaces all-dot segments
  (., .., ...) with underscores so a developer-controlled agent.Name
  cannot escape the root directory on Linux (where Path.GetInvalidFileNameChars
  only contains NUL and '/').

- InputConverter.TryDecodeTextDataUri rejects encoded payloads larger than
  16 MiB before calling Convert.FromBase64String, preventing a single
  oversized data URI from triggering a multi-megabyte allocation.

- Adds unit tests covering both fixes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Linux-only failure in SaveSessionAsync_SanitizesInvalidPathCharactersAsync

'?' is in Path.GetInvalidFileNameChars only on Windows, not on Linux/macOS,
so the test failed on Ubuntu in CI. Use Path.GetInvalidFileNameChars()[0]
(skipping NUL) to pick a guaranteed-invalid character for the running OS,
and assert the result no longer contains it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address claude-opus-4.6 security/reliability review feedback

WorkflowSession.cs:
- ExecutorFailedEvent handler no longer leaks the internal executor ID
  in error messages. Mirror the WorkflowErrorEvent pattern: surface the
  exception's Message when _includeExceptionDetails is true, fall back
  to the generic 'An error occurred while executing the workflow.' otherwise.
  This also resolves the failing WorkflowHostSmokeTests assertions.

FileSystemAgentSessionStore.cs:
- GetSessionPath no longer has a write side effect. Directory.CreateDirectory
  for the per-agent bucket is now performed only on the SaveSessionAsync
  path, so a read miss on GetSessionAsync no longer leaves an empty
  directory on disk.
- Adds GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsync
  to lock in the no-side-effect-on-read contract.

OutputConverterTests.cs:
- Strengthen ConvertUpdatesToEventsAsync_ToolApprovalRequest_NonFunctionToolCall_SkippedAsync
  to assert exactly one event (the terminal ResponseCompletedEvent) so a
  spurious output-item-added/-done leak would now fail the test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review: clean up comments and rename TryParseArguments

- Remove Python-codebase references from C# XML docs and inline comments.
- Drop fix-history comments referring to previously-resolved issues.
- Drop `Defense-in-depth:` prefixes; keep the concrete `what & why`.
- Drop `previously we kept only the trailing message` comment in
  DeclarativeWorkflowExecutor; just describe current loop behavior.
- Rename InputConverter.TryParseArguments to ParseFunctionArgumentsObject
  to make the intent obvious at the call site.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review: collision-free Sanitize, MAF-style refactors

- FileSystemAgentSessionStore.Sanitize now percent-encodes invalid chars
  (and `%` itself) instead of replacing them with `_`, eliminating
  collisions like `foo/bar` vs `foo_bar` mapping to the same bucket.
  All-dot segments encode every dot so Windows trailing-dot trimming
  cannot reintroduce a navigable name.
- AddFoundryResponses XML doc updated to accurately describe the default
  store root (/.checkpoints when hosted, {cwd}/.checkpoints locally).
- DeclarativeWorkflowExecutor.ConfigureChatProtocolRoutes now uses exact
  type equality instead of IsAssignableFrom so a broad TInput (e.g.
  object) does not skip registering IEnumerable<ChatMessage>, which
  ChatProtocolExtensions.IsChatProtocol requires verbatim.
- SendActivityExecutor uses context.YieldOutputAsync(response) instead
  of manually constructing AgentResponseEvent, so the activity will
  participate in any future OutputFilter coverage.
- WorkflowSession handles AgentResponseEvent in its own switch case,
  avoiding the second typecheck against output.Data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(workflows): bridge declarative HITL through Foundry hosting via IExternalRequestEnvelope

Introduce a new public interface IExternalRequestEnvelope in
Microsoft.Agents.AI.Workflows that lets the runtime peek through a
declarative-layer envelope without taking a circular reference back into
the declarative package. ExternalInputRequest (declarative) implements
it; ExternalInputResponse is constructed via the request's CreateResponse
factory. WorkflowSession unwraps inner AIContent on the request side and
rewraps the client's ChatMessage reply into an ExternalInputResponse on
the response side. PortableValue cannot deserialize directly into an
interface, so TryGetRequestEnvelope resolves the concrete type via
RequestPortInfo.RequestType (TypeId -> Type.GetType) before casting.

Public WorkflowHarness contract preserved: InvokeFunctionToolExecutor
and WorkflowActionVisitor are unchanged from upstream, so public
InvokeToolWorkflowTest scenarios continue to drive
ExternalInputRequest / ExternalInputResponse directly through the
harness.

AgentFrameworkResponseHandler: skip prior conversation history replay
when an existing session is being resumed (workflow checkpoint already
holds the prior messages).

WorkflowSession: when includeExceptionDetails is opted in, also unwrap
DeclarativeActionException so HITL failures are debuggable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 22:09:54 +00:00

1224 lines
40 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Extensions.AI;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
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 = new 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_FunctionToolCallOutput_ReturnsToolMessage()
{
var funcOutput = new OutputItemFunctionToolCallOutput(
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 = new OutputItemReasoningItem("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 = new 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);
}
// === Tool-approval (HITL) wire-format coverage ===
[Fact]
public void ConvertItemsToMessages_McpApprovalRequest_ProducesToolApprovalRequest()
{
var item = new ItemMcpApprovalRequest(
id: "mcpr_" + new string('a', 50),
serverLabel: "agent_framework",
name: "get_weather",
arguments: "{\"city\":\"Seattle\"}");
var messages = InputConverter.ConvertItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalRequestContent>(Assert.Single(messages[0].Contents));
Assert.Equal(item.Id, content.RequestId);
var fc = Assert.IsType<FunctionCallContent>(content.ToolCall);
Assert.Equal("get_weather", fc.Name);
Assert.NotNull(fc.Arguments);
Assert.Equal("Seattle", fc.Arguments!["city"]?.ToString());
}
[Fact]
public void ConvertItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse_FallsBackToWireIdWhenNoMapping()
{
var wireId = "mcpr_" + new string('a', 50);
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: true);
var messages = InputConverter.ConvertItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(wireId, content.RequestId);
Assert.True(content.Approved);
}
[Fact]
public void ConvertItemsToMessages_McpApprovalResponse_ResolvesAfRequestIdFromStateBag()
{
const string AfRequestId = "af_request_xyz";
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
var stateBag = new AgentSessionStateBag();
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: false);
var messages = InputConverter.ConvertItemsToMessages([item], stateBag);
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(AfRequestId, content.RequestId);
Assert.False(content.Approved);
}
[Fact]
public void ConvertOutputItemsToMessages_McpApprovalRequest_ProducesToolApprovalRequest()
{
var item = new OutputItemMcpApprovalRequest(
id: "mcpr_" + new string('b', 50),
serverLabel: "agent_framework",
name: "delete_file",
arguments: "{}");
var messages = InputConverter.ConvertOutputItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalRequestContent>(Assert.Single(messages[0].Contents));
Assert.Equal(item.Id, content.RequestId);
Assert.Equal("delete_file", Assert.IsType<FunctionCallContent>(content.ToolCall).Name);
}
[Fact]
public void ConvertOutputItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse()
{
const string AfRequestId = "af_request_history";
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
var stateBag = new AgentSessionStateBag();
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
var item = new OutputItemMcpApprovalResponseResource(
id: "ar_history_id",
approvalRequestId: wireId,
approve: true);
var messages = InputConverter.ConvertOutputItemsToMessages([item], stateBag);
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(AfRequestId, content.RequestId);
Assert.True(content.Approved);
}
[Fact]
public void ConvertItemsToMessages_McpApprovalRequest_MalformedArguments_PreservesRaw()
{
var item = new ItemMcpApprovalRequest(
id: "mcpr_" + new string('c', 50),
serverLabel: "agent_framework",
name: "noisy",
arguments: "not valid json");
var messages = InputConverter.ConvertItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalRequestContent>(Assert.Single(messages[0].Contents));
var fc = Assert.IsType<FunctionCallContent>(content.ToolCall);
Assert.NotNull(fc.Arguments);
Assert.Equal("not valid json", fc.Arguments!["_raw"]?.ToString());
}
// ── input_file data-URI decoding (TryDecodeTextDataUri) ──
[Fact]
public void ConvertInputToMessages_FileContentWithTextDataUri_DecodesToTextContent()
{
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("hello world"));
var input = new[]
{
new
{
type = "message",
id = "msg_text_uri",
status = "completed",
role = "user",
content = new[] { new { type = "input_file", file_data = $"data:text/plain;base64,{encoded}" } }
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("hello world", text.Text);
}
[Fact]
public void ConvertInputToMessages_FileContentWithTextDataUriAndFilename_PrefixesFilenameInDecodedText()
{
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("body"));
var input = new[]
{
new
{
type = "message",
id = "msg_text_uri_name",
status = "completed",
role = "user",
content = new[]
{
new
{
type = "input_file",
filename = "notes.txt",
file_data = $"data:text/plain;base64,{encoded}"
}
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.StartsWith("[File: notes.txt]", text.Text, StringComparison.Ordinal);
Assert.Contains("body", text.Text, StringComparison.Ordinal);
}
[Fact]
public void ConvertInputToMessages_FileContentWithNonTextDataUri_RemainsDataContent()
{
// image/png data URIs must NOT be decoded as text — only text/* is decoded inline.
var input = new[]
{
new
{
type = "message",
id = "msg_image_uri",
status = "completed",
role = "user",
content = new[]
{
new { type = "input_file", file_data = "data:image/png;base64,iVBORw0KGgo=" }
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
Assert.IsType<DataContent>(Assert.Single(messages[0].Contents));
}
[Fact]
public void ConvertInputToMessages_FileContentWithMalformedDataUri_FallsBackToDataContent()
{
// Missing ;base64, marker — TryDecodeTextDataUri should return false and the
// original payload survives as DataContent.
var input = new[]
{
new
{
type = "message",
id = "msg_bad_uri",
status = "completed",
role = "user",
content = new[]
{
new { type = "input_file", file_data = "data:text/plain,not-base64-payload" }
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
Assert.IsType<DataContent>(Assert.Single(messages[0].Contents));
}
[Fact]
public void ConvertInputToMessages_FileContentWithFileUrlAndFilename_PropagatesFilename()
{
var input = new[]
{
new
{
type = "message",
id = "msg_url_name",
status = "completed",
role = "user",
content = new[]
{
new
{
type = "input_file",
file_url = "https://example.com/doc.pdf",
filename = "doc.pdf"
}
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
var uri = Assert.IsType<UriContent>(Assert.Single(messages[0].Contents));
Assert.NotNull(uri.AdditionalProperties);
Assert.Equal("doc.pdf", uri.AdditionalProperties!["filename"]);
}
[Fact]
public void ConvertInputToMessages_FileContentWithFileIdAndFilename_PropagatesFilename()
{
var input = new[]
{
new
{
type = "message",
id = "msg_id_name",
status = "completed",
role = "user",
content = new[]
{
new
{
type = "input_file",
file_id = "file_abc123",
filename = "doc.pdf"
}
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
var hosted = Assert.IsType<HostedFileContent>(Assert.Single(messages[0].Contents));
Assert.NotNull(hosted.AdditionalProperties);
Assert.Equal("doc.pdf", hosted.AdditionalProperties!["filename"]);
}
// ── C2: SDK content types passing through ItemMessage / OutputItemMessage ──
[Fact]
public void ConvertItemsToMessages_SdkTextContent_ProducesTextContent()
{
var msg = new ItemMessage(
MessageRole.User,
new MessageContent[] { new Azure.AI.AgentServer.Responses.Models.TextContent("plain text") });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("plain text", text.Text);
}
[Fact]
public void ConvertItemsToMessages_SummaryTextContent_ProducesTextContent()
{
var msg = new ItemMessage(
MessageRole.Assistant,
new MessageContent[] { new SummaryTextContent("a summary") });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("a summary", text.Text);
}
[Fact]
public void ConvertItemsToMessages_ReasoningTextContent_ProducesTextReasoningContent()
{
var msg = new ItemMessage(
MessageRole.Assistant,
new MessageContent[] { new MessageContentReasoningTextContent("internal reasoning") });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var reasoning = Assert.IsType<TextReasoningContent>(Assert.Single(messages[0].Contents));
Assert.Equal("internal reasoning", reasoning.Text);
}
[Fact]
public void ConvertItemsToMessages_ComputerScreenshotContent_HttpUrl_ProducesUriContent()
{
var screenshot = new ComputerScreenshotContent(
imageUrl: new Uri("https://example.com/screen.png"),
fileId: null!,
detail: default);
var msg = new ItemMessage(MessageRole.User, new MessageContent[] { screenshot });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var uri = Assert.IsType<UriContent>(Assert.Single(messages[0].Contents));
Assert.Equal("https://example.com/screen.png", uri.Uri.ToString());
}
[Fact]
public void ConvertItemsToMessages_ComputerScreenshotContent_DataUri_ProducesDataContent()
{
var screenshot = new ComputerScreenshotContent(
imageUrl: new Uri("data:image/png;base64,iVBORw0KGgo="),
fileId: null!,
detail: default);
var msg = new ItemMessage(MessageRole.User, new MessageContent[] { screenshot });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var data = Assert.IsType<DataContent>(Assert.Single(messages[0].Contents));
Assert.StartsWith("data:image", data.Uri);
}
[Fact]
public void ConvertOutputItemsToMessages_SummaryTextContent_ProducesTextContent()
{
var outputMsg = new OutputItemMessage(
id: "out_summary",
role: MessageRole.Assistant,
content: new MessageContent[] { new SummaryTextContent("output summary") },
status: MessageStatus.Completed);
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("output summary", text.Text);
}
[Fact]
public void ConvertOutputItemsToMessages_ReasoningTextContent_ProducesTextReasoningContent()
{
var outputMsg = new OutputItemMessage(
id: "out_reasoning",
role: MessageRole.Assistant,
content: new MessageContent[] { new MessageContentReasoningTextContent("output reasoning") },
status: MessageStatus.Completed);
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
var reasoning = Assert.IsType<TextReasoningContent>(Assert.Single(messages[0].Contents));
Assert.Equal("output reasoning", reasoning.Text);
}
[Fact]
public void ConvertOutputItemsToMessages_ComputerScreenshotContent_ProducesUriContent()
{
var screenshot = new ComputerScreenshotContent(
imageUrl: new Uri("https://example.com/output-screen.png"),
fileId: null!,
detail: default);
var outputMsg = new OutputItemMessage(
id: "out_screenshot",
role: MessageRole.Assistant,
content: new MessageContent[] { screenshot },
status: MessageStatus.Completed);
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
var uri = Assert.IsType<UriContent>(Assert.Single(messages[0].Contents));
Assert.Equal("https://example.com/output-screen.png", uri.Uri.ToString());
}
[Fact]
public void ConvertOutputItemsToMessages_SdkTextContent_ProducesTextContent()
{
var outputMsg = new OutputItemMessage(
id: "out_text",
role: MessageRole.Assistant,
content: new MessageContent[] { new Azure.AI.AgentServer.Responses.Models.TextContent("sdk text") },
status: MessageStatus.Completed);
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("sdk text", text.Text);
}
[Fact]
public void ConvertInputToMessages_OversizedTextDataUri_FallsBackToDataContent()
{
// The decoder must reject oversized base64 payloads so a malicious or
// misconfigured client cannot trigger a multi-megabyte allocation.
// We construct a base64 payload whose encoded length exceeds the 16 MiB cap
// (using a tiny but valid base64 unit repeated to keep the test fast).
const int OverLimit = (16 * 1024 * 1024) + 4;
var encoded = new string('A', OverLimit);
var dataUri = "data:text/plain;base64," + encoded;
var input = new[]
{
new
{
type = "message",
id = "msg_oversize",
status = "completed",
role = "user",
content = new[]
{
new
{
type = "input_file",
file_data = dataUri,
filename = "huge.txt",
}
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
// Should NOT have decoded into a TextContent (which would have allocated).
Assert.DoesNotContain(messages[0].Contents, c => c is MeaiTextContent t && t.Text.Length > 1024);
// Should have fallen back to DataContent (carrying the original opaque blob).
Assert.Contains(messages[0].Contents, c => c is DataContent);
}
}