.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>
This commit is contained in:
Ben Thomas
2026-05-04 15:09:54 -07:00
committed by GitHub
Unverified
parent 330d3d7165
commit 4b5a8478de
21 changed files with 1886 additions and 112 deletions
@@ -19,8 +19,7 @@ namespace Azure.AI.Projects;
/// Foundry toolbox definitions as server-side tools.
/// </summary>
/// <remarks>
/// These extensions mirror Python's <c>FoundryChatClient.get_toolbox()</c> pattern,
/// allowing a single call on the project client to retrieve tools ready for use
/// Provides a single call on the project client to retrieve tools ready for use
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
@@ -77,23 +77,31 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// 4. Convert input: history + current input → ChatMessage[]
var messages = new List<ChatMessage>();
// Load conversation history if available
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
// Load conversation history only for fresh sessions. When a session already exists
// (e.g. resuming a workflow paused at an external-input port), the workflow's
// checkpointed state already contains the prior turns' messages — replaying history
// would re-drive completed actions and break HITL resume semantics.
var isResume = !string.IsNullOrWhiteSpace(sessionConversationId)
&& session?.StateBag?.Count > 0;
if (!isResume)
{
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history));
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
{
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag));
}
}
// Load and convert current input items
var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
if (inputItems.Count > 0)
{
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems));
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems, session?.StateBag));
}
else
{
// Fall back to raw request input
messages.AddRange(InputConverter.ConvertInputToMessages(request));
messages.AddRange(InputConverter.ConvertInputToMessages(request, session?.StateBag));
}
// 5. Build chat options
@@ -191,6 +199,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
stream,
session?.StateBag,
cancellationToken).GetAsyncEnumerator(cancellationToken);
try
{
@@ -0,0 +1,261 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides a file-system backed implementation of <see cref="AgentSessionStore"/> that persists
/// the agent-framework's serialized <see cref="AgentSession"/> state for each (agent, conversation)
/// pair to disk. This complements Foundry storage (which owns conversation messages, agent
/// definitions, and threads) — it is not a replacement for it.
/// </summary>
/// <remarks>
/// <para>
/// The session JSON stored here is the AF runtime's own state (workflow checkpoint manager,
/// pending external requests, internal port state) that is required to resume an
/// <see cref="AgentSession"/> across HTTP requests or process restarts but is not part of
/// Foundry's data model.
/// </para>
/// <para>
/// When running in a Foundry hosted environment, sessions are stored under the well-known
/// <c>/.checkpoints</c> path; locally, they fall under <c>{cwd}/.checkpoints</c>. The session
/// JSON produced when the agent serializes the session already contains the workflow's
/// in-memory checkpoint manager state, so a single file per (agent, conversation) pair is
/// sufficient to resume long-running workflows across process restarts.
/// </para>
/// <para>
/// Files are written atomically via a temp-file + <see cref="File.Move(string, string, bool)"/>
/// rename so a partially-written file cannot be observed by a concurrent reader.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FileSystemAgentSessionStore : AgentSessionStore
{
/// <summary>
/// The well-known absolute path used when running inside a Foundry hosted environment.
/// </summary>
public const string HostedCheckpointDirectory = "/.checkpoints";
/// <summary>
/// The directory name used under the current working directory when running locally.
/// </summary>
public const string LocalCheckpointDirectoryName = ".checkpoints";
/// <summary>
/// Initializes a new instance of the <see cref="FileSystemAgentSessionStore"/> class
/// that stores serialized sessions under <paramref name="rootDirectory"/>.
/// </summary>
/// <param name="rootDirectory">
/// The absolute or relative directory where session files will be written.
/// The directory is created on first write if it does not already exist.
/// </param>
public FileSystemAgentSessionStore(string rootDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory);
this.RootDirectory = Path.GetFullPath(rootDirectory);
}
/// <summary>
/// Gets the root directory under which session files are written.
/// </summary>
public string RootDirectory { get; }
/// <summary>
/// Creates a <see cref="FileSystemAgentSessionStore"/> rooted at the default location:
/// <see cref="HostedCheckpointDirectory"/> when running in a Foundry hosted environment,
/// otherwise <see cref="LocalCheckpointDirectoryName"/> under the current working directory.
/// </summary>
/// <returns>A new <see cref="FileSystemAgentSessionStore"/> instance.</returns>
public static FileSystemAgentSessionStore CreateDefault()
{
string root = FoundryEnvironment.IsHosted
? HostedCheckpointDirectory
: Path.Combine(Environment.CurrentDirectory, LocalCheckpointDirectoryName);
return new FileSystemAgentSessionStore(root);
}
/// <inheritdoc/>
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
ArgumentNullException.ThrowIfNull(session);
JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
Directory.CreateDirectory(this.RootDirectory);
string path = this.GetSessionPath(agent, conversationId);
string? parentDir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(parentDir))
{
Directory.CreateDirectory(parentDir);
}
// Each save writes to its own temp file before atomically renaming over the
// destination. Last writer wins for the final file, but no reader can observe
// a torn or partially-written JSON document.
string tempPath = $"{path}.{Guid.NewGuid():N}.tmp";
try
{
using (FileStream stream = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
using (Utf8JsonWriter writer = new(stream))
{
serialized.WriteTo(writer);
}
File.Move(tempPath, path, overwrite: true);
}
catch
{
try { File.Delete(tempPath); } catch { /* best-effort cleanup */ }
throw;
}
}
/// <inheritdoc/>
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
string path = this.GetSessionPath(agent, conversationId);
if (!File.Exists(path))
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
if (bytes.Length == 0)
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
// Parse and clone so the document buffer can be released.
using JsonDocument document = JsonDocument.Parse(bytes);
JsonElement element = document.RootElement.Clone();
return await agent.DeserializeSessionAsync(element, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private string GetSessionPath(AIAgent agent, string conversationId)
{
// When agent.Name is set we bucket sessions into a per-agent subdirectory so
// multiple keyed agents sharing a single in-process default store cannot
// collide on the same conversationId. agent.Id is intentionally NOT used
// because it is regenerated on every startup for in-memory-defined agents.
string fileName = $"{Sanitize(conversationId)}.json";
if (string.IsNullOrEmpty(agent.Name))
{
return Path.Combine(this.RootDirectory, fileName);
}
string agentDir = Path.Combine(this.RootDirectory, Sanitize(agent.Name!));
return Path.Combine(agentDir, fileName);
}
private static string Sanitize(string value)
{
// Percent-encode every character that is invalid in a filename, plus '%' itself
// so the encoding is unambiguous. This is reversible and avoids the collision
// hazard of a lossy character substitution (e.g. "foo/bar" and "foo_bar" sharing
// a sanitized name).
char[] invalid = Path.GetInvalidFileNameChars();
int encodedLength = ComputeEncodedLength(value, invalid);
// stackalloc is bounded so an externally-controlled length cannot crash the
// hosting process with StackOverflowException.
const int StackLimit = 512;
string sanitized;
if (encodedLength <= StackLimit)
{
Span<char> buffer = stackalloc char[encodedLength];
SanitizeCore(value, invalid, buffer);
sanitized = new string(buffer);
}
else
{
char[] rented = ArrayPool<char>.Shared.Rent(encodedLength);
try
{
Span<char> buffer = rented.AsSpan(0, encodedLength);
SanitizeCore(value, invalid, buffer);
sanitized = new string(buffer);
}
finally
{
ArrayPool<char>.Shared.Return(rented);
}
}
// '.' and '..' are valid filename characters but resolve to current/parent
// directory when used as a bare path component. Windows additionally strips
// trailing dots from filenames, so a segment like "..." would survive on disk
// as "" and a partial-encode like "%2E.." would survive as "%2E". Encode every
// dot in any all-dot segment so the result has no special meaning to the OS.
if (sanitized.Length > 0 && IsAllDots(sanitized))
{
return string.Concat(Enumerable.Repeat("%2E", sanitized.Length));
}
return sanitized;
}
private static int ComputeEncodedLength(string value, char[] invalid)
{
int extra = 0;
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
if (c == '%' || Array.IndexOf(invalid, c) >= 0)
{
extra += 2; // 1 char ('%' or invalid) becomes 3 chars ("%XX")
}
}
return value.Length + extra;
}
private static bool IsAllDots(string value)
{
for (int i = 0; i < value.Length; i++)
{
if (value[i] != '.')
{
return false;
}
}
return true;
}
private static void SanitizeCore(string value, char[] invalid, Span<char> buffer)
{
int j = 0;
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
if (c == '%' || Array.IndexOf(invalid, c) >= 0)
{
buffer[j++] = '%';
buffer[j++] = HexChar((c >> 4) & 0xF);
buffer[j++] = HexChar(c & 0xF);
}
else
{
buffer[j++] = c;
}
}
}
private static char HexChar(int n) => (char)(n < 10 ? '0' + n : 'A' + n - 10);
}
@@ -32,9 +32,6 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
/// handles tool execution — the agent process does not invoke tools locally.
/// </para>
/// <para>
/// This is the dotnet equivalent of Python's <c>FoundryChatClient.get_toolbox()</c> pattern.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryToolbox
@@ -3,10 +3,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Extensions.AI;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
using SdkTextContent = Azure.AI.AgentServer.Responses.Models.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -19,14 +21,15 @@ internal static class InputConverter
/// Converts the SDK <see cref="CreateResponse"/> request input items into a list of <see cref="ChatMessage"/>.
/// </summary>
/// <param name="request">The create response request from the SDK.</param>
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
/// <returns>A list of chat messages representing the request input.</returns>
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request)
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request, AgentSessionStateBag? stateBag = null)
{
var messages = new List<ChatMessage>();
foreach (var item in request.GetInputExpanded())
{
var message = ConvertInputItemToMessage(item);
var message = ConvertInputItemToMessage(item, stateBag);
if (message is not null)
{
messages.Add(message);
@@ -40,14 +43,15 @@ internal static class InputConverter
/// Converts resolved SDK <see cref="Item"/> input items into <see cref="ChatMessage"/> instances.
/// </summary>
/// <param name="items">The resolved input items from the SDK context.</param>
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
/// <returns>A list of chat messages.</returns>
public static List<ChatMessage> ConvertItemsToMessages(IReadOnlyList<Item> items)
public static List<ChatMessage> ConvertItemsToMessages(IReadOnlyList<Item> items, AgentSessionStateBag? stateBag = null)
{
var messages = new List<ChatMessage>();
foreach (var item in items)
{
var message = ConvertInputItemToMessage(item);
var message = ConvertInputItemToMessage(item, stateBag);
if (message is not null)
{
messages.Add(message);
@@ -61,14 +65,15 @@ internal static class InputConverter
/// Converts resolved SDK <see cref="OutputItem"/> history/input items into <see cref="ChatMessage"/> instances.
/// </summary>
/// <param name="items">The resolved output items from the SDK context.</param>
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
/// <returns>A list of chat messages.</returns>
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items)
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items, AgentSessionStateBag? stateBag = null)
{
var messages = new List<ChatMessage>();
foreach (var item in items)
{
var message = ConvertOutputItemToMessage(item);
var message = ConvertOutputItemToMessage(item, stateBag);
if (message is not null)
{
messages.Add(message);
@@ -128,13 +133,15 @@ internal static class InputConverter
return markers;
}
private static ChatMessage? ConvertInputItemToMessage(Item item)
private static ChatMessage? ConvertInputItemToMessage(Item item, AgentSessionStateBag? stateBag)
{
return item switch
{
ItemMessage msg => ConvertItemMessage(msg),
FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput),
ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall),
ItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest(approvalRequest.Id, approvalRequest.Name, approvalRequest.Arguments),
MCPApprovalResponse approvalResponse => ConvertMcpApprovalResponse(approvalResponse.ApprovalRequestId, approvalResponse.Approve, stateBag),
ItemReferenceParam => null,
_ => null
};
@@ -152,43 +159,23 @@ internal static class InputConverter
case MessageContentInputTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case SdkTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case SummaryTextContent summary:
contents.Add(new MeaiTextContent(summary.Text));
break;
case MessageContentReasoningTextContent reasoning:
contents.Add(new TextReasoningContent(reasoning.Text));
break;
case MessageContentInputImageContent imageContent:
if (imageContent.ImageUrl is not null)
{
var url = imageContent.ImageUrl.ToString();
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
contents.Add(new DataContent(url, "image/*"));
}
else
{
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
}
}
else if (!string.IsNullOrEmpty(imageContent.FileId))
{
contents.Add(new HostedFileContent(imageContent.FileId));
}
AppendImageContent(contents, imageContent.ImageUrl, imageContent.FileId);
break;
case MessageContentInputFileContent fileContent:
if (fileContent.FileUrl is not null)
{
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileData))
{
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileId))
{
contents.Add(new HostedFileContent(fileContent.FileId));
}
else if (!string.IsNullOrEmpty(fileContent.Filename))
{
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
}
AppendFileContent(contents, fileContent.FileUrl, fileContent.FileData, fileContent.FileId, fileContent.Filename);
break;
case ComputerScreenshotContent screenshot:
AppendImageContent(contents, screenshot.ImageUrl, screenshot.FileId);
break;
}
}
@@ -231,13 +218,63 @@ internal static class InputConverter
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
}
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item)
/// <summary>
/// Converts an inbound <c>mcp_approval_request</c> wire item (from history replay
/// or fresh-input) to a <see cref="ToolApprovalRequestContent"/> wrapping a
/// <see cref="FunctionCallContent"/>.
/// </summary>
private static ChatMessage ConvertMcpApprovalRequest(string id, string name, string? arguments)
{
var functionCall = new FunctionCallContent(id, name, ParseFunctionArgumentsObject(arguments));
return new ChatMessage(
ChatRole.Assistant,
[new ToolApprovalRequestContent(id, functionCall)]);
}
/// <summary>
/// Converts an inbound <c>mcp_approval_response</c> wire item to a
/// <see cref="ToolApprovalResponseContent"/>. Looks up the original AF request id
/// via <see cref="ToolApprovalIdMap"/>; falls back to the wire id when the mapping
/// is unavailable. Carries a placeholder <see cref="FunctionCallContent"/> because
/// the original tool-call details are not echoed by clients in the response item.
/// </summary>
private static ChatMessage ConvertMcpApprovalResponse(string approvalRequestId, bool approve, AgentSessionStateBag? stateBag)
{
var afRequestId = ToolApprovalIdMap.Resolve(stateBag, approvalRequestId);
var placeholderFunctionCall = new FunctionCallContent(afRequestId, "mcp_approval");
return new ChatMessage(
ChatRole.User,
[new ToolApprovalResponseContent(afRequestId, approve, placeholderFunctionCall)]);
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing tool-call arguments from SDK input.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing tool-call arguments from SDK input.")]
private static Dictionary<string, object?>? ParseFunctionArgumentsObject(string? arguments)
{
if (string.IsNullOrWhiteSpace(arguments))
{
return null;
}
try
{
return JsonSerializer.Deserialize<Dictionary<string, object?>>(arguments);
}
catch (JsonException)
{
return new Dictionary<string, object?> { ["_raw"] = arguments };
}
}
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item, AgentSessionStateBag? stateBag)
{
return item switch
{
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput),
OutputItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest(approvalRequest.Id, approvalRequest.Name, approvalRequest.Arguments),
OutputItemMcpApprovalResponseResource approvalResponse => ConvertMcpApprovalResponse(approvalResponse.ApprovalRequestId, approvalResponse.Approve, stateBag),
OutputItemReasoningItem => null,
_ => null
};
@@ -258,46 +295,26 @@ internal static class InputConverter
case MessageContentOutputTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case SdkTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case SummaryTextContent summary:
contents.Add(new MeaiTextContent(summary.Text));
break;
case MessageContentReasoningTextContent reasoning:
contents.Add(new TextReasoningContent(reasoning.Text));
break;
case MessageContentRefusalContent refusal:
contents.Add(new MeaiTextContent($"[Refusal: {refusal.Refusal}]"));
break;
case MessageContentInputImageContent imageContent:
if (imageContent.ImageUrl is not null)
{
var url = imageContent.ImageUrl.ToString();
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
contents.Add(new DataContent(url, "image/*"));
}
else
{
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
}
}
else if (!string.IsNullOrEmpty(imageContent.FileId))
{
contents.Add(new HostedFileContent(imageContent.FileId));
}
AppendImageContent(contents, imageContent.ImageUrl, imageContent.FileId);
break;
case MessageContentInputFileContent fileContent:
if (fileContent.FileUrl is not null)
{
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileData))
{
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileId))
{
contents.Add(new HostedFileContent(fileContent.FileId));
}
else if (!string.IsNullOrEmpty(fileContent.Filename))
{
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
}
AppendFileContent(contents, fileContent.FileUrl, fileContent.FileData, fileContent.FileId, fileContent.Filename);
break;
case ComputerScreenshotContent screenshot:
AppendImageContent(contents, screenshot.ImageUrl, screenshot.FileId);
break;
}
}
@@ -310,6 +327,127 @@ internal static class InputConverter
return new ChatMessage(role, contents);
}
private static void AppendImageContent(List<AIContent> contents, Uri? imageUrl, string? fileId)
{
if (imageUrl is not null)
{
var url = imageUrl.ToString();
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
contents.Add(new DataContent(url, "image/*"));
}
else
{
contents.Add(new UriContent(imageUrl, "image/*"));
}
}
else if (!string.IsNullOrEmpty(fileId))
{
contents.Add(new HostedFileContent(fileId));
}
}
private static void AppendFileContent(List<AIContent> contents, Uri? fileUrl, string? fileData, string? fileId, string? filename)
{
if (fileUrl is not null)
{
var content = new UriContent(fileUrl, "application/octet-stream");
if (!string.IsNullOrEmpty(filename))
{
content.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
}
contents.Add(content);
return;
}
if (!string.IsNullOrEmpty(fileData))
{
// If the data URI carries text/* content, decode it inline as TextContent so
// {System.LastMessageText} (and other text-only consumers) sees the file's
// body rather than an opaque blob.
if (TryDecodeTextDataUri(fileData, filename, out var decodedText))
{
contents.Add(new MeaiTextContent(decodedText));
}
else
{
var dataContent = new DataContent(fileData, "application/octet-stream");
if (!string.IsNullOrEmpty(filename))
{
dataContent.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
}
contents.Add(dataContent);
}
return;
}
if (!string.IsNullOrEmpty(fileId))
{
var hosted = new HostedFileContent(fileId);
if (!string.IsNullOrEmpty(filename))
{
hosted.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
}
contents.Add(hosted);
return;
}
if (!string.IsNullOrEmpty(filename))
{
contents.Add(new MeaiTextContent($"[File: {filename}]"));
}
}
private static bool TryDecodeTextDataUri(string dataUri, string? filename, out string text)
{
// Cap the encoded payload so an oversized client-supplied data URI cannot
// trigger an unbounded allocation in Convert.FromBase64String. 16 MiB
// encoded → ~12 MiB decoded, well above any realistic text/* file we'd
// want to inline as content while still bounding the worst case.
const int MaxEncodedLength = 16 * 1024 * 1024;
text = string.Empty;
if (!dataUri.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
return false;
}
const string Marker = ";base64,";
int markerIndex = dataUri.IndexOf(Marker, StringComparison.OrdinalIgnoreCase);
if (markerIndex < 0)
{
return false;
}
string mediaType = dataUri.Substring("data:".Length, markerIndex - "data:".Length);
if (!mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
string encoded = dataUri.Substring(markerIndex + Marker.Length);
if (encoded.Length > MaxEncodedLength)
{
return false;
}
try
{
byte[] bytes = Convert.FromBase64String(encoded);
string decoded = Encoding.UTF8.GetString(bytes);
text = string.IsNullOrEmpty(filename) ? decoded : $"[File: {filename}]\n{decoded}";
return true;
}
catch (FormatException)
{
return false;
}
catch (DecoderFallbackException)
{
return false;
}
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK output history.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK output history.")]
private static ChatMessage ConvertOutputItemFunctionCall(OutputItemFunctionToolCall funcCall)
@@ -30,6 +30,7 @@ internal static class OutputConverter
/// </summary>
/// <param name="updates">The agent response updates to convert.</param>
/// <param name="stream">The SDK event stream builder.</param>
/// <param name="stateBag">Optional session state bag used to persist tool-approval id mappings across turns.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of SDK response stream events (excluding lifecycle events).</returns>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")]
@@ -37,6 +38,7 @@ internal static class OutputConverter
public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEventsAsync(
IAsyncEnumerable<AgentResponseUpdate> updates,
ResponseEventStream stream,
AgentSessionStateBag? stateBag = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ResponseUsage? accumulatedUsage = null;
@@ -51,8 +53,11 @@ internal static class OutputConverter
{
cancellationToken.ThrowIfCancellationRequested();
// Handle workflow events from RawRepresentation
if (update.RawRepresentation is WorkflowEvent workflowEvent)
// Handle workflow events from RawRepresentation.
// If the update also carries Contents (e.g. WorkflowSession unwrapped a
// WorkflowErrorEvent or ExecutorFailedEvent into an ErrorContent payload),
// fall through to the content-processing path below so those are emitted.
if (update.RawRepresentation is WorkflowEvent workflowEvent && update.Contents.Count == 0)
{
// Close any open message builder before emitting workflow items
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
@@ -166,6 +171,54 @@ internal static class OutputConverter
break;
}
case ToolApprovalRequestContent approvalRequest when approvalRequest.ToolCall is FunctionCallContent approvalFunctionCall:
{
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
previousMessageId = null;
// The Responses API only standardizes the MCP-flavored approval primitive.
// We emit the AF tool-approval request as `mcp_approval_request` with
// server_label="agent_framework" — declaring the AF runtime as the virtual
// server holding this call. The SDK requires a strict {prefix}_{50hex}
// wire-id format, so we hash the AF RequestId and persist the
// wireId↔afRequestId mapping in the session state bag for later lookup
// when the matching `mcp_approval_response` arrives on a subsequent turn.
var wireId = ToolApprovalIdMap.ComputeWireId(approvalRequest.RequestId);
ToolApprovalIdMap.Record(stateBag, wireId, approvalRequest.RequestId);
var approvalArguments = approvalFunctionCall.Arguments is not null
? JsonSerializer.Serialize(approvalFunctionCall.Arguments)
: "{}";
var approvalItem = new OutputItemMcpApprovalRequest(
wireId,
"agent_framework",
approvalFunctionCall.Name,
approvalArguments);
var approvalBuilder = stream.AddOutputItem<OutputItemMcpApprovalRequest>(wireId);
yield return approvalBuilder.EmitAdded(approvalItem);
yield return approvalBuilder.EmitDone(approvalItem);
break;
}
case ToolApprovalRequestContent:
// Approval requests must wrap a FunctionCallContent (handled above).
// Any other shape has no representation in the Responses wire format.
break;
case ToolApprovalResponseContent:
// Approval responses originate from the client and travel inbound; the
// workflow does not re-emit them. Skip silently if encountered.
break;
case UsageContent usageContent when usageContent.Details is not null:
{
accumulatedUsage = ConvertUsage(usageContent.Details, accumulatedUsage);
@@ -49,7 +49,7 @@ public static class FoundryHostingExtensions
{
ArgumentNullException.ThrowIfNull(services);
services.AddResponsesServer();
services.TryAddSingleton<AgentSessionStore, InMemoryAgentSessionStore>();
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}
@@ -76,7 +76,7 @@ public static class FoundryHostingExtensions
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="agent">The agent instance to register.</param>
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, an in-memory session store will be used.</param>
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, a file-system session store is used, rooted at <c>/.checkpoints</c> when running in a Foundry hosted environment and <c>{cwd}/.checkpoints</c> locally.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null)
{
@@ -84,7 +84,7 @@ public static class FoundryHostingExtensions
ArgumentNullException.ThrowIfNull(agent);
services.AddResponsesServer();
agentSessionStore ??= new InMemoryAgentSessionStore();
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
if (!string.IsNullOrWhiteSpace(agent.Name))
{
@@ -185,8 +185,6 @@ public static class FoundryHostingExtensions
/// <summary>
/// The ActivitySource name for the Responses hosting pipeline.
/// Matches the value previously exposed by <c>AgentHostTelemetry.ResponsesSourceName</c>
/// in <c>Azure.AI.AgentServer.Core</c>.
/// </summary>
private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses";
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Helper for translating between agent-framework tool-approval request ids and the
/// strict-format wire ids required by the Responses Server SDK <c>mcp_approval_request</c>
/// item type. The mapping is persisted in <see cref="AgentSessionStateBag"/> so an
/// approval request emitted on one HTTP turn can be matched to the response posted
/// back on the next turn.
/// </summary>
internal static class ToolApprovalIdMap
{
/// <summary>
/// State-bag key used to store the wire-id ↔ AF-request-id mapping.
/// </summary>
public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.ToolApprovalIdMap";
/// <summary>
/// SDK item-id format constraints: <c>{prefix}_{50_or_48_chars}</c>. We use the
/// canonical <c>mcpr_</c> prefix and a SHA-256 truncated to 50 hex chars (25 bytes)
/// for deterministic, format-safe wire ids.
/// </summary>
public static string ComputeWireId(string afRequestId)
{
ArgumentNullException.ThrowIfNull(afRequestId);
#if NET10_0_OR_GREATER
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId), hash);
#else
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId));
#endif
// 25 bytes = 50 hex chars (matches SDK body length 50).
return "mcpr_" + Convert.ToHexString(hash).AsSpan(0, 50).ToString();
}
/// <summary>
/// Records the wire-id → AF-request-id mapping in the supplied state bag.
/// </summary>
public static void Record(AgentSessionStateBag? stateBag, string wireId, string afRequestId)
{
if (stateBag is null)
{
return;
}
var map = stateBag.GetValue<Dictionary<string, string>>(StateBagKey)
?? new Dictionary<string, string>(StringComparer.Ordinal);
map[wireId] = afRequestId;
stateBag.SetValue(StateBagKey, map);
}
/// <summary>
/// Looks up the AF request id for a given wire id. Returns the wire id verbatim
/// when no mapping is present (best-effort fallback that keeps converters total).
/// </summary>
public static string Resolve(AgentSessionStateBag? stateBag, string wireId)
{
if (stateBag?.GetValue<Dictionary<string, string>>(StateBagKey) is { } map
&& map.TryGetValue(wireId, out var afRequestId))
{
return afRequestId;
}
return wireId;
}
}
@@ -56,6 +56,13 @@ public static class DeclarativeWorkflowBuilder
/// <param name="options">Configuration options for workflow execution.</param>
/// <param name="inputTransform">An optional function to transform the input message into a <see cref="ChatMessage"/>.</param>
/// <returns>The <see cref="Workflow"/> that corresponds with the YAML object model.</returns>
/// <remarks>
/// The returned workflow's root executor accepts <typeparamref name="TInput"/>,
/// <see cref="ChatMessage"/>, <see cref="System.Collections.Generic.IEnumerable{T}"/> of
/// <see cref="ChatMessage"/>, <see cref="string"/>, and <see cref="TurnToken"/>. This
/// makes the workflow usable both for direct invocation and for hosting via
/// <see cref="WorkflowHostingExtensions.AsAIAgent(Workflow, string?, string?, string?, IWorkflowExecutionEnvironment?, bool, bool)"/>.
/// </remarks>
public static Workflow Build<TInput>(
TextReader yamlReader,
DeclarativeWorkflowOptions options,
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
@@ -8,7 +9,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents a request for external input.
/// </summary>
public sealed class ExternalInputRequest
public sealed class ExternalInputRequest : IExternalRequestEnvelope
{
/// <summary>
/// The source message that triggered the request for external input.
@@ -30,4 +31,47 @@ public sealed class ExternalInputRequest
{
this.AgentResponse = new AgentResponse(new ChatMessage(ChatRole.User, text));
}
/// <inheritdoc />
/// <remarks>
/// Prefers <see cref="ToolApprovalRequestContent"/> (when the workflow declared
/// <c>requireApproval: true</c>) over <see cref="FunctionCallContent"/> so that
/// hosts which speak the approval protocol see the approval-bearing content.
/// </remarks>
AIContent? IExternalRequestEnvelope.GetInnerRequestContent()
{
IList<ChatMessage>? messages = this.AgentResponse?.Messages;
if (messages is null)
{
return null;
}
foreach (ChatMessage message in messages)
{
foreach (AIContent content in message.Contents)
{
if (content is ToolApprovalRequestContent toolApprovalRequest)
{
return toolApprovalRequest;
}
}
}
foreach (ChatMessage message in messages)
{
foreach (AIContent content in message.Contents)
{
if (content is FunctionCallContent functionCall)
{
return functionCall;
}
}
}
return null;
}
/// <inheritdoc />
object IExternalRequestEnvelope.CreateResponse(IList<ChatMessage> messages)
=> new ExternalInputResponse(messages);
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
@@ -13,6 +14,24 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
/// <remarks>
/// In addition to the strongly-typed <typeparamref name="TInput"/> route inherited from
/// <see cref="Executor{TInput}"/>, this executor also accepts <see cref="string"/>,
/// <see cref="ChatMessage"/>, <see cref="IEnumerable{T}"/> of <see cref="ChatMessage"/>,
/// <see cref="ChatMessage"/><c>[]</c>, and <see cref="TurnToken"/> so that the workflow
/// satisfies <see cref="ChatProtocolExtensions.IsChatProtocol"/>. This makes the workflow
/// usable both for direct <c>Run.SendMessageAsync(input)</c> invocations and for hosting
/// via <see cref="WorkflowHostingExtensions.AsAIAgent(Workflow, string?, string?, string?, IWorkflowExecutionEnvironment?, bool, bool)"/>.
///
/// <para>
/// Each non-<see cref="TurnToken"/> input drives the declarative graph forward
/// immediately. The host's <see cref="TurnToken"/> arrives after the message batch and
/// is treated as a no-op because the inbound message has already been processed.
/// External responses (HITL function results) bypass the start executor entirely
/// (they are routed via <c>WorkflowSession.SendResponseAsync</c> to request-info
/// executors), so the start executor only ever sees a single inbound batch per turn.
/// </para>
/// </remarks>
internal sealed class DeclarativeWorkflowExecutor<TInput>(
string workflowId,
DeclarativeWorkflowOptions options,
@@ -26,29 +45,143 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
return default;
}
/// <inheritdoc/>
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
public override ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
ChatMessage input = inputTransform.Invoke(message);
return this.AdvanceAsync(input, context, cancellationToken);
}
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
// Inherit the TInput route + method/class attributes (e.g. SendsMessage on HandleAsync).
ProtocolBuilder result = base.ConfigureProtocol(protocolBuilder);
// Add the chat-protocol input shapes so the workflow satisfies IsChatProtocol
// and can be hosted via AsAIAgent. Skip any shape that already matches TInput
// (the inherited route handles that case via inputTransform).
return result.ConfigureRoutes(this.ConfigureChatProtocolRoutes)
.SendsMessage<ActionExecutorResult>();
}
private void ConfigureChatProtocolRoutes(RouteBuilder routeBuilder)
{
Type tInput = typeof(TInput);
// Skip an exact-type match because RouteBuilder.AddHandler throws on duplicate
// registrations for the same message type. Equality (not IsAssignableFrom) is
// also what ChatProtocolExtensions.IsChatProtocol checks, so always registering
// IEnumerable<ChatMessage> when TInput is broader (e.g. object) keeps the
// workflow chat-protocol-compliant.
if (tInput != typeof(string))
{
routeBuilder.AddHandler<string>(this.HandleStringAsync);
}
if (tInput != typeof(ChatMessage))
{
routeBuilder.AddHandler<ChatMessage>(this.HandleChatMessageAsync);
}
if (tInput != typeof(IEnumerable<ChatMessage>))
{
routeBuilder.AddHandler<IEnumerable<ChatMessage>>(this.HandleChatMessagesAsync);
}
if (tInput != typeof(ChatMessage[]))
{
routeBuilder.AddHandler<ChatMessage[]>(this.HandleChatMessageArrayAsync);
}
if (tInput != typeof(TurnToken))
{
routeBuilder.AddHandler<TurnToken>(this.HandleTurnTokenAsync);
}
}
private ValueTask HandleStringAsync(string message, IWorkflowContext context, CancellationToken cancellationToken)
{
return this.AdvanceAsync(new ChatMessage(ChatRole.User, message), context, cancellationToken);
}
private ValueTask HandleChatMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken)
{
return this.AdvanceAsync(message, context, cancellationToken);
}
private async ValueTask HandleChatMessagesAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
var list = messages as IList<ChatMessage> ?? new List<ChatMessage>(messages);
if (list.Count == 0)
{
return;
}
for (int i = 0; i < list.Count; i++)
{
await this.AdvanceAsync(list[i], context, cancellationToken, finalizeTurn: i == list.Count - 1).ConfigureAwait(false);
}
}
private async ValueTask HandleChatMessageArrayAsync(ChatMessage[] messages, IWorkflowContext context, CancellationToken cancellationToken)
{
if (messages.Length == 0)
{
return;
}
for (int i = 0; i < messages.Length; i++)
{
await this.AdvanceAsync(messages[i], context, cancellationToken, finalizeTurn: i == messages.Length - 1).ConfigureAwait(false);
}
}
// The host sends a TurnToken after the message batch; the message has already
// driven the graph forward, so we treat the token as a no-op here.
private ValueTask HandleTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
{
return default;
}
private async ValueTask AdvanceAsync(ChatMessage input, IWorkflowContext context, CancellationToken cancellationToken, bool finalizeTurn = true)
{
// No state to restore if we're starting from the beginning.
state.SetInitialized();
DeclarativeWorkflowContext declarativeContext = new(context, state);
ChatMessage input = inputTransform.Invoke(message);
string? conversationId = options.ConversationId;
// Conversation id resolution prefers state already persisted by a prior turn,
// so multi-turn invocations reuse the same backend conversation rather than
// creating a fresh one each turn.
string? conversationId = declarativeContext.GetWorkflowConversation();
if (string.IsNullOrWhiteSpace(conversationId))
{
conversationId = options.ConversationId;
}
bool conversationCreated = false;
if (string.IsNullOrWhiteSpace(conversationId))
{
conversationId = await options.AgentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false);
conversationCreated = true;
}
await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false);
if (conversationCreated || !string.Equals(declarativeContext.GetWorkflowConversation(), conversationId, StringComparison.Ordinal))
{
await declarativeContext.QueueConversationUpdateAsync(conversationId!, isExternal: true, cancellationToken).ConfigureAwait(false);
}
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId!, input, cancellationToken).ConfigureAwait(false);
// Use the original input for System.LastMessage to ensure Text is preserved (the
// service may strip text on round-trip), but substitute server-side media references
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
if (finalizeTurn)
{
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -6,6 +6,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
@@ -19,6 +20,14 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
string activityText = this.Engine.Format(messageActivity.Text).Trim();
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
// Route through YieldOutputAsync so the activity participates in the workflow's
// output-filter pipeline. The runner currently special-cases AgentResponse to
// produce an AgentResponseEvent identical to the one we'd build by hand, so this
// is behavior-preserving today and forward-compatible if filtering is ever
// applied to agent responses.
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}
return default;
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Optional interface implemented by request payload types that wrap underlying
/// AI content (such as <see cref="FunctionCallContent"/> or
/// <see cref="ToolApprovalRequestContent"/>) and define a paired response envelope.
/// </summary>
/// <remarks>
/// <para>
/// This abstraction allows higher-level layers (e.g., declarative workflows) to define
/// their own request/response envelope types while still allowing
/// <c>WorkflowSession</c> to surface the inner content to hosts on the request side
/// and to wrap incoming responses back into the envelope on the response side -
/// without the runtime taking a reference back to the higher-level layer.
/// </para>
/// <para>
/// When an <c>ExternalRequest.Data</c> payload implements this interface, the
/// runtime uses <see cref="GetInnerRequestContent"/> to drive wire serialization
/// for hosts (so a host receives a normal <see cref="FunctionCallContent"/> or
/// <see cref="ToolApprovalRequestContent"/>), and uses <see cref="CreateResponse"/>
/// to wrap the host's response payload back into the envelope expected by the
/// workflow's request port.
/// </para>
/// </remarks>
public interface IExternalRequestEnvelope
{
/// <summary>
/// Returns the inner AI content that should be delivered to the host on the wire.
/// Typically a <see cref="FunctionCallContent"/> or <see cref="ToolApprovalRequestContent"/>.
/// </summary>
/// <returns>The inner content, or <c>null</c> if no suitable inner content is available.</returns>
AIContent? GetInnerRequestContent();
/// <summary>
/// Wraps the supplied response messages into the envelope's matching response type
/// for delivery to the workflow's request port.
/// </summary>
/// <param name="messages">The response messages, typically containing a
/// <see cref="FunctionResultContent"/> and/or <see cref="ToolApprovalResponseContent"/>.</param>
/// <returns>An instance of the envelope's response type wrapping <paramref name="messages"/>.</returns>
object CreateResponse(IList<ChatMessage> messages);
}
@@ -287,24 +287,93 @@ internal sealed class WorkflowSession : AgentSession
hasMatchedResponseForStartExecutor);
}
/// <summary>
/// Resolves the concrete request payload type from <see cref="RequestPortInfo.RequestType"/>
/// and returns it as an <see cref="IExternalRequestEnvelope"/> if the type implements that
/// abstraction. Resolving via the concrete <see cref="TypeId"/> (rather than asking the
/// PortableValue to deserialize directly to <see cref="IExternalRequestEnvelope"/>) is
/// required because checkpointed payloads round-trip as JSON which cannot be deserialized
/// to an interface; the concrete type populates the deserialization cache so subsequent
/// interface assignment succeeds.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
private static bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope)
{
envelope = null;
TypeId requestType = request.PortInfo.RequestType;
Type? concreteType = Type.GetType($"{requestType.TypeName}, {requestType.AssemblyName}", throwOnError: false);
if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType))
{
return false;
}
if (!request.TryGetDataAs(concreteType, out object? data) || data is not IExternalRequestEnvelope env)
{
return false;
}
envelope = env;
return true;
}
/// <summary>
/// Creates the workflow-facing request content surfaced in response updates.
/// </summary>
private static AIContent CreateRequestContentForDelivery(ExternalRequest request) => request switch
private static AIContent CreateRequestContentForDelivery(ExternalRequest request)
{
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent)
=> CloneFunctionCallContent(functionCallContent, externalRequest.RequestId),
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
=> CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId),
ExternalRequest externalRequest
=> externalRequest.ToFunctionCall(),
};
// If the request payload is a higher-layer envelope (e.g., a declarative
// ExternalInputRequest), surface its inner FCC/TARC to the host on the wire.
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
{
AIContent? inner = envelope.GetInnerRequestContent();
if (inner is ToolApprovalRequestContent toolApprovalRequest)
{
return CloneToolApprovalRequestContent(toolApprovalRequest, request.RequestId);
}
if (inner is FunctionCallContent functionCall)
{
return CloneFunctionCallContent(functionCall, request.RequestId);
}
}
return request switch
{
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent)
=> CloneFunctionCallContent(functionCallContent, externalRequest.RequestId),
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
=> CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId),
ExternalRequest externalRequest
=> externalRequest.ToFunctionCall(),
};
}
/// <summary>
/// Rewrites workflow-facing response content back to the original agent-owned content ID.
/// </summary>
private static object NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request)
{
// If the request payload is a higher-layer envelope, recover the original
// CallId/RequestId from the inner content and ask the envelope to wrap the
// response back into its paired response type for delivery to the request port.
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
{
AIContent? inner = envelope.GetInnerRequestContent();
AIContent payload = (content, inner) switch
{
(FunctionResultContent functionResult, FunctionCallContent functionCall)
=> CloneFunctionResultContent(functionResult, functionCall.CallId),
(FunctionResultContent functionResult, ToolApprovalRequestContent toolApprovalRequest)
=> CloneFunctionResultContent(functionResult, toolApprovalRequest.ToolCall.CallId),
(ToolApprovalResponseContent toolApprovalResponse, ToolApprovalRequestContent toolApprovalRequest)
=> CloneToolApprovalResponseContent(toolApprovalResponse, toolApprovalRequest.RequestId),
_ => content,
};
ChatMessage message = new(ChatRole.Tool, [payload]);
return envelope.CreateResponse([message]);
}
switch (content)
{
// If we got a FRC, and were expecting a FRC (because the request started out as a FCC, rather than getting converted to
@@ -427,10 +496,41 @@ internal sealed class WorkflowSession : AgentSession
break;
case ExecutorFailedEvent executorFailed:
// Mirror WorkflowErrorEvent: never expose internal workflow graph
// identifiers (executor ID) to the client. Surface the exception
// message only when the host opts in via _includeExceptionDetails.
Exception? executorException = executorFailed.Data;
while (executorException is { InnerException: not null }
&& (executorException is TargetInvocationException
|| executorException.GetType().Name == "DeclarativeActionException"))
{
executorException = executorException.InnerException;
}
string executorMessage = this._includeExceptionDetails && executorException != null
? executorException.Message
: "An error occurred while executing the workflow.";
yield return this.CreateUpdate(this.LastResponseId, evt, new ErrorContent(executorMessage));
break;
case SuperStepCompletedEvent stepCompleted:
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
goto default;
case AgentResponseEvent agentResponse:
if (!this._includeWorkflowOutputsInResponse)
{
goto default;
}
foreach (ChatMessage message in agentResponse.Response.Messages)
{
yield return this.CreateUpdate(this.LastResponseId, evt, message);
}
break;
case WorkflowOutputEvent output:
IEnumerable<ChatMessage>? updateMessages = output.Data switch
{
@@ -0,0 +1,302 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Foundry.Hosting;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public sealed class FileSystemAgentSessionStoreTests : IDisposable
{
private readonly string _root;
public FileSystemAgentSessionStoreTests()
{
this._root = Path.Combine(Path.GetTempPath(), "fs-session-store-tests-" + Guid.NewGuid().ToString("N"));
}
public void Dispose()
{
try
{
if (Directory.Exists(this._root))
{
Directory.Delete(this._root, recursive: true);
}
}
catch
{
// best-effort cleanup
}
}
[Fact]
public void Constructor_ResolvesRootDirectoryToFullPath()
{
var store = new FileSystemAgentSessionStore(this._root);
Assert.Equal(Path.GetFullPath(this._root), store.RootDirectory);
}
[Fact]
public void Constructor_NullOrWhitespaceRoot_Throws()
{
Assert.Throws<ArgumentNullException>(() => new FileSystemAgentSessionStore(null!));
Assert.Throws<ArgumentException>(() => new FileSystemAgentSessionStore(""));
Assert.Throws<ArgumentException>(() => new FileSystemAgentSessionStore(" "));
}
[Fact]
public async Task GetSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAgentAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent();
var session = await store.GetSessionAsync(agent, "conv-1");
Assert.NotNull(session);
Assert.Equal(1, agent.CreateCalls);
Assert.Equal(0, agent.DeserializeCalls);
}
[Fact]
public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsFreshSessionAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
Directory.CreateDirectory(store.RootDirectory);
File.WriteAllText(Path.Combine(store.RootDirectory, "conv-empty.json"), string.Empty);
var agent = new TestAgent();
var session = await store.GetSessionAsync(agent, "conv-empty");
Assert.NotNull(session);
Assert.Equal(1, agent.CreateCalls);
Assert.Equal(0, agent.DeserializeCalls);
}
[Fact]
public async Task SaveSessionAsync_CreatesRootDirectoryIfMissingAsync()
{
var nested = Path.Combine(this._root, "nested", "deeper");
var store = new FileSystemAgentSessionStore(nested);
Assert.False(Directory.Exists(nested));
var agent = new TestAgent("{\"workflow\":\"x\"}");
await store.SaveSessionAsync(agent, "conv-2", NewSession());
Assert.True(Directory.Exists(nested));
Assert.True(File.Exists(Path.Combine(nested, "conv-2.json")));
}
[Fact]
public async Task SaveSessionAsync_ThenGetSessionAsync_RoundTripsViaAgentSerializerAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent("{\"foo\":42}");
await store.SaveSessionAsync(agent, "round-trip", NewSession());
await store.GetSessionAsync(agent, "round-trip");
Assert.Equal(1, agent.SerializeCalls);
Assert.Equal(1, agent.DeserializeCalls);
Assert.NotNull(agent.LastDeserialized);
Assert.Equal(JsonValueKind.Object, agent.LastDeserialized!.Value.ValueKind);
Assert.Equal(42, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32());
}
[Fact]
public async Task SaveSessionAsync_TwoAgentsSameConversationId_DoNotCollideAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agentA = new TestAgent("{\"who\":\"a\"}", name: "AgentA");
var agentB = new TestAgent("{\"who\":\"b\"}", name: "AgentB");
await store.SaveSessionAsync(agentA, "shared-conv", NewSession());
await store.SaveSessionAsync(agentB, "shared-conv", NewSession());
// Agents with distinct Names get distinct subdirectories so neither overwrites the other.
var pathA = Path.Combine(store.RootDirectory, "AgentA", "shared-conv.json");
var pathB = Path.Combine(store.RootDirectory, "AgentB", "shared-conv.json");
Assert.True(File.Exists(pathA));
Assert.True(File.Exists(pathB));
Assert.Contains("\"a\"", File.ReadAllText(pathA), StringComparison.Ordinal);
Assert.Contains("\"b\"", File.ReadAllText(pathB), StringComparison.Ordinal);
}
[Fact]
public async Task SaveSessionAsync_LongConversationId_DoesNotStackOverflowAsync()
{
// Keep the value < typical OS file-name limits (~255 chars) so the file write
// succeeds, but long enough to force Sanitize past its small-input fast path.
var store = new FileSystemAgentSessionStore(this._root);
var conversationId = new string('a', 200);
var agent = new TestAgent();
await store.SaveSessionAsync(agent, conversationId, NewSession());
var files = Directory.GetFiles(store.RootDirectory, "*.json");
Assert.Single(files);
}
[Fact]
public async Task SaveSessionAsync_SanitizesInvalidPathCharactersAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent();
// Pick an invalid filename char for the current OS. The set differs by platform
// (e.g. '?' is invalid on Windows but not on Linux), so we must select dynamically.
var invalidChars = Path.GetInvalidFileNameChars();
Assert.NotEmpty(invalidChars);
char invalid = invalidChars[0];
// Avoid NUL specifically because some shells/loggers handle it oddly; prefer
// the next character if available.
if (invalid == '\0' && invalidChars.Length > 1)
{
invalid = invalidChars[1];
}
var conversationId = $"id-with{invalid}invalid-chars";
await store.SaveSessionAsync(agent, conversationId, NewSession());
var files = Directory.GetFiles(store.RootDirectory, "*.json");
Assert.Single(files);
var fileName = Path.GetFileName(files[0]);
Assert.DoesNotContain(invalid.ToString(), fileName, StringComparison.Ordinal);
Assert.Contains("id-with", fileName, StringComparison.Ordinal);
Assert.Contains("invalid-chars", fileName, StringComparison.Ordinal);
}
[Fact]
public async Task SaveSessionAsync_ConcurrentSavesOnSameConversation_DoNotCollideOnTempFileAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent("{\"x\":1}");
// Fan out N concurrent saves; with a fixed temp filename ("path.tmp") this would
// race on FileMode.Create / Move. Verify they all complete successfully.
var tasks = new List<Task>();
for (int i = 0; i < 16; i++)
{
tasks.Add(store.SaveSessionAsync(agent, "concurrent", NewSession()).AsTask());
}
await Task.WhenAll(tasks);
Assert.True(File.Exists(Path.Combine(store.RootDirectory, "concurrent.json")));
var leftoverTempFiles = Directory.GetFiles(store.RootDirectory, "*.tmp");
Assert.Empty(leftoverTempFiles);
}
[Theory]
[InlineData(".")]
[InlineData("..")]
[InlineData("...")]
public async Task SaveSessionAsync_AgentNameIsDotSegment_DoesNotEscapeRootAsync(string agentName)
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent(name: agentName);
await store.SaveSessionAsync(agent, "conv-dots", NewSession());
// The session file must land inside RootDirectory, not in (or above) it as a sibling.
var allFiles = Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories);
Assert.Single(allFiles);
var fullPath = Path.GetFullPath(allFiles[0]);
Assert.StartsWith(Path.GetFullPath(this._root) + Path.DirectorySeparatorChar, fullPath, StringComparison.Ordinal);
// The bucket directory name must not be a navigable dot-segment. After
// percent-encoding every dot in an all-dot segment, names like ".", "..", and
// "..." become "%2E", "%2E%2E", "%2E%2E%2E" — distinct, OS-neutral filenames.
var bucketName = Path.GetFileName(Path.GetDirectoryName(fullPath)!);
Assert.NotEmpty(bucketName);
Assert.NotEqual(".", bucketName);
Assert.NotEqual("..", bucketName);
Assert.DoesNotContain(bucketName, c => c == '.');
}
[Fact]
public async Task SaveSessionAsync_DistinctNamesWithInvalidChars_ProduceDistinctFilesAsync()
{
// Percent-encoding must keep otherwise-colliding inputs distinct: under the
// earlier underscore-substitution scheme, "foo/bar" and "foo_bar" both sanitized
// to "foo_bar" and would have shared a session bucket on disk.
var store = new FileSystemAgentSessionStore(this._root);
var agentSlash = new TestAgent(name: "foo/bar");
var agentUnderscore = new TestAgent(name: "foo_bar");
await store.SaveSessionAsync(agentSlash, "conv-1", NewSession());
await store.SaveSessionAsync(agentUnderscore, "conv-1", NewSession());
var bucketDirs = Directory.GetDirectories(store.RootDirectory);
Assert.Equal(2, bucketDirs.Length);
}
[Fact]
public async Task GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsync()
{
// Read operations must not have side effects on the file system.
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent(name: "agent-with-bucket");
var session = await store.GetSessionAsync(agent, "missing-id");
Assert.NotNull(session);
Assert.False(Directory.Exists(this._root), "Read miss must not create the root directory.");
}
private static TestSession NewSession() => new();
private sealed class TestSession : AgentSession
{
}
private sealed class TestAgent : AIAgent
{
private readonly string _serializedJson;
private readonly string? _name;
public TestAgent(string serializedJson = "{}", string? name = null)
{
this._serializedJson = serializedJson;
this._name = name;
}
public override string? Name => this._name;
public int CreateCalls { get; private set; }
public int SerializeCalls { get; private set; }
public int DeserializeCalls { get; private set; }
public JsonElement? LastDeserialized { get; private set; }
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
{
this.CreateCalls++;
return new ValueTask<AgentSession>(NewSession());
}
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
this.SerializeCalls++;
using var doc = JsonDocument.Parse(this._serializedJson);
return new ValueTask<JsonElement>(doc.RootElement.Clone());
}
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
this.DeserializeCalls++;
this.LastDeserialized = serializedState.Clone();
return new ValueTask<AgentSession>(NewSession());
}
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<Extensions.AI.ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<Extensions.AI.ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
@@ -757,4 +757,467 @@ public class InputConverterTests
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);
}
}
@@ -204,7 +204,7 @@ public class OutputConverterTests
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream, cts.Token))
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream, cancellationToken: cts.Token))
{
// Should throw before yielding
}
@@ -1068,6 +1068,133 @@ public class OutputConverterTests
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
// === Tool-approval (HITL) wire-format coverage ===
[Fact]
public async Task ConvertUpdatesToEventsAsync_ToolApprovalRequest_EmitsMcpApprovalRequestAsync()
{
var (stream, _) = CreateTestStream();
var stateBag = new AgentSessionStateBag();
const string AfRequestId = "af_request_abc";
var functionCall = new FunctionCallContent("call_1", "delete_resource",
new Dictionary<string, object?> { ["target"] = "db" });
var approval = new ToolApprovalRequestContent(AfRequestId, functionCall);
var update = new AgentResponseUpdate { Contents = [approval] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream, stateBag))
{
events.Add(evt);
}
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var item = Assert.IsType<OutputItemMcpApprovalRequest>(added.Item);
Assert.Equal("agent_framework", item.ServerLabel);
Assert.Equal("delete_resource", item.Name);
Assert.Contains("\"target\":\"db\"", item.Arguments);
Assert.StartsWith("mcpr_", item.Id);
// Mapping persisted to state bag.
Assert.Equal(AfRequestId, ToolApprovalIdMap.Resolve(stateBag, item.Id));
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_ToolApprovalRequest_NonFunctionToolCall_SkippedAsync()
{
// ToolCall implementations that aren't FunctionCallContent (e.g. raw MCP calls)
// are intentionally NOT emitted — mirrors the OpenAI Hosting layer's behavior.
var (stream, _) = CreateTestStream();
var unknownTool = new RawToolCallContent("call_x");
var approval = new ToolApprovalRequestContent("af_x", unknownTool);
var update = new AgentResponseUpdate { Contents = [approval] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.DoesNotContain(events.OfType<ResponseOutputItemAddedEvent>(),
e => e.Item is OutputItemMcpApprovalRequest);
// Defense in depth: only the terminal ResponseCompletedEvent should be emitted.
// No spurious output-item-added/output-item-done events should leak for the
// unsupported tool-call shape.
Assert.Single(events);
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_ToolApprovalResponse_NotReEmittedAsync()
{
var (stream, _) = CreateTestStream();
var fc = new FunctionCallContent("call_1", "noop");
var response = new ToolApprovalResponseContent("af_x", true, fc);
var update = new AgentResponseUpdate { Contents = [response] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
// Approval responses are inbound-only; output side should silently drop them
// and emit only the terminal completed event.
Assert.Single(events);
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
// D1: WorkflowEvent in RawRepresentation but Contents is non-empty → fall through to content path.
[Fact]
public async Task ConvertUpdatesToEventsAsync_WorkflowEventWithTextContent_FlowsThroughContentPathAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
MessageId = "msg_workflow_text",
RawRepresentation = new ExecutorInvokedEvent("exec_x", "invoked"),
Contents = [new MeaiTextContent("payload from workflow event")],
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
// Content path must have been taken: a text-delta event must be emitted from the payload.
Assert.Contains(events, e => e is ResponseTextDeltaEvent);
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_WorkflowEventWithErrorContent_EmitsFailedAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
RawRepresentation = new ExecutorFailedEvent("exec_y", new InvalidOperationException("boom")),
Contents = [new ErrorContent("boom")],
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
// ErrorContent should drive a failed event rather than being swallowed by the workflow branch.
Assert.Contains(events, e => e is ResponseFailedEvent);
}
private sealed class RawToolCallContent : ToolCallContent
{
public RawToolCallContent(string callId) : base(callId) { }
}
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
{
foreach (var item in source)
@@ -11,6 +11,7 @@
"min_action_count": 8,
"min_message_count": 1,
"min_response_count": 1,
"max_response_count": 4,
"actions": {
"start": [
"conversation_create1",
@@ -11,7 +11,7 @@
"min_action_count": 6,
"max_action_count": -1,
"min_response_count": 2,
"max_response_count": 8,
"max_response_count": 9,
"min_message_count": 4,
"max_message_count": -1,
"actions": {
@@ -9,7 +9,10 @@
"validation": {
"conversation_count": 1,
"min_action_count": 3,
"min_response_count": 0,
"min_message_count": 0,
"max_message_count": 0,
"min_response_count": 1,
"max_response_count": 1,
"actions": {
"start": [
"set_user_input",
@@ -1,8 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
@@ -27,6 +29,14 @@ public sealed class SendActivityExecutorTest(ITestOutputHelper output) : Workflo
// Assert
VerifyModel(model, action);
Assert.Contains(events, e => e is MessageActivityEvent);
// The executor must also emit an AgentResponseEvent carrying the activity text
// so workflow consumers (hosting runtime, UIs) can surface it as an agent turn.
AgentResponseEvent agentEvent = Assert.Single(events.OfType<AgentResponseEvent>());
Assert.Equal(action.Id, agentEvent.ExecutorId);
ChatMessage message = Assert.Single(agentEvent.Response.Messages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Test activity message", message.Text);
}
private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null)