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

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

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

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

* fix: use DeclarativeWorkflowContext when reading workflow conversation id

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

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

* fix: surface ExecutorFailedEvent as ErrorContent in AsAIAgent response

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

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

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

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

* improve: walk inner exceptions when surfacing ExecutorFailedEvent

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

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

* Surface declarative SendActivity output as chat content

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

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

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

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

Adds FileSystemAgentSessionStore that writes the serialized AgentSession JSON

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

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

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

FileCheckpointStorage pattern so multi-turn workflow state survives process

restarts without requiring callers to wire up storage themselves.

AddFoundryResponses now defaults to FileSystemAgentSessionStore.CreateDefault()

instead of InMemoryAgentSessionStore; callers can still override via DI.

Also fixes {System.LastMessageText} resolving empty: DeclarativeWorkflowExecutor

.AdvanceAsync was passing the message rehydrated from CreateMessageAsync to

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

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

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

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

* Close multi-modal input parity gaps with python foundry_hosting

InputConverter now mirrors the python _responses.py content handling:

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

- Plain TextContent and SummaryTextContent map to MEAI TextContent.

- MessageContentReasoningTextContent maps to MEAI TextReasoningContent.

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

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

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

  hosted/url file references preserve filename as AdditionalProperties.

Image/file extraction logic is extracted into shared AppendImageContent and

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

switches. Existing 37 InputConverter tests still pass.

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

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

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

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

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

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

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

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

* Address PR review feedback for hosted-declarative-dotnet

FileSystemAgentSessionStore reliability/scoping:

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

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

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

DeclarativeWorkflowExecutor chat-protocol routing:

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

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

Tests:

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

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

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

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

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

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

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

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

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

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

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

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

- Adds unit tests covering both fixes.

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

* Fix Linux-only failure in SaveSessionAsync_SanitizesInvalidPathCharactersAsync

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

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

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

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

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

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

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

* Address PR review: clean up comments and rename TryParseArguments

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

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

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

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

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

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

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

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

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

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

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

---------

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

1208 lines
49 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using Moq;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
public class OutputConverterTests
{
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
{
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
var request = new CreateResponse { Model = "test-model" };
var stream = new ResponseEventStream(mockContext.Object, request);
return (stream, mockContext);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_EmptyStream_EmitsCompletedAsync()
{
var (stream, _) = CreateTestStream();
var updates = ToAsync(Array.Empty<AgentResponseUpdate>());
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream))
{
events.Add(evt);
}
Assert.Single(events);
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_SingleTextUpdate_EmitsMessageAndCompletedAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
MessageId = "msg_1",
Contents = [new MeaiTextContent("Hello, world!")]
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
// Expected: MessageAdded, TextAdded, TextDelta, TextDone, ContentDone, MessageDone, Completed
Assert.True(events.Count >= 5, $"Expected at least 5 events, got {events.Count}");
Assert.IsType<ResponseOutputItemAddedEvent>(events[0]);
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_MultipleTextUpdates_EmitsStreamingDeltasAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Hello, ")] },
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("world!")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Should have two text delta events among the others
Assert.True(events.Count >= 6, $"Expected at least 6 events, got {events.Count}");
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionCall_EmitsFunctionCallEventsAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
Contents = [new FunctionCallContent("call_1", "get_weather",
new Dictionary<string, object?> { ["city"] = "Seattle" })]
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
// Should have: FuncAdded, ArgsDelta, ArgsDone, FuncDone, Completed
Assert.IsType<ResponseOutputItemAddedEvent>(events[0]);
Assert.IsType<ResponseCompletedEvent>(events[^1]);
Assert.True(events.Count >= 4, $"Expected at least 4 events for function call, got {events.Count}");
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_ErrorContent_EmitsFailedAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
Contents = [new ErrorContent("Something went wrong")]
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.IsType<ResponseFailedEvent>(events[^1]);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_ErrorContent_DoesNotEmitCompletedAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
Contents = [new ErrorContent("Failure")]
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.DoesNotContain(events, e => e is ResponseCompletedEvent);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_UsageContent_IncludesUsageInCompletedAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate
{
MessageId = "msg_1",
Contents = [new MeaiTextContent("Hi")]
},
new AgentResponseUpdate
{
Contents = [new UsageContent(new UsageDetails
{
InputTokenCount = 10,
OutputTokenCount = 5,
TotalTokenCount = 15
})]
}
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
var completedEvent = events.OfType<ResponseCompletedEvent>().SingleOrDefault();
Assert.NotNull(completedEvent);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_ReasoningContent_EmitsReasoningEventsAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
Contents = [new TextReasoningContent("Let me think about this...")]
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
// Should have: ReasoningAdded, SummaryPartAdded, TextDelta, TextDone, SummaryDone, ReasoningDone, Completed
Assert.True(events.Count >= 5, $"Expected at least 5 events for reasoning, got {events.Count}");
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_CancellationRequested_ThrowsAsync()
{
var (stream, _) = CreateTestStream();
using var cts = new CancellationTokenSource();
cts.Cancel();
var updates = ToAsync(new[] { new AgentResponseUpdate { Contents = [new MeaiTextContent("test")] } });
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream, cancellationToken: cts.Token))
{
// Should throw before yielding
}
});
}
// F-03
[Fact]
public async Task ConvertUpdatesToEventsAsync_EmptyTextContent_NoTextDeltaEmittedAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("")] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.DoesNotContain(events, e => e is ResponseTextDeltaEvent);
Assert.Contains(events, e => e is ResponseCompletedEvent);
}
// F-04
[Fact]
public async Task ConvertUpdatesToEventsAsync_NullTextContent_NoTextDeltaEmittedAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent(null!)] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.DoesNotContain(events, e => e is ResponseTextDeltaEvent);
Assert.Contains(events, e => e is ResponseCompletedEvent);
}
// F-07
[Fact]
public async Task ConvertUpdatesToEventsAsync_DifferentMessageIds_CreatesMultipleMessagesAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("First")] },
new AgentResponseUpdate { MessageId = "msg_2", Contents = [new MeaiTextContent("Second")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.Equal(2, events.OfType<ResponseOutputItemAddedEvent>().Count());
}
// F-08
[Fact]
public async Task ConvertUpdatesToEventsAsync_NullMessageIds_TreatedAsSameMessageAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { MessageId = null, Contents = [new MeaiTextContent("First")] },
new AgentResponseUpdate { MessageId = null, Contents = [new MeaiTextContent("Second")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
}
// G-02
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionCallClosesOpenMessageAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("thinking...")] },
new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "search", new Dictionary<string, object?> { ["q"] = "test" })] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.Equal(2, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Equal(2, events.OfType<ResponseOutputItemDoneEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// G-03
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionCallWithNullArguments_EmitsEmptyJsonAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
Contents = [new FunctionCallContent("call_1", "do_something", null)]
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// G-04
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionCallWithEmptyCallId_GeneratesCallIdAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
Contents = [new FunctionCallContent("", "do_something", new Dictionary<string, object?> { ["x"] = 1 })]
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.Contains(events, e => e is ResponseOutputItemAddedEvent);
}
// G-05
[Fact]
public async Task ConvertUpdatesToEventsAsync_MultipleFunctionCalls_EmitsSeparateBuildersAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "func_a", new Dictionary<string, object?> { ["a"] = 1 })] },
new AgentResponseUpdate { Contents = [new FunctionCallContent("call_2", "func_b", new Dictionary<string, object?> { ["b"] = 2 })] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.Equal(2, events.OfType<ResponseOutputItemAddedEvent>().Count());
}
// H-02
[Fact]
public async Task ConvertUpdatesToEventsAsync_ReasoningWithNullText_EmitsEmptyStringAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { Contents = [new TextReasoningContent(null)] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.True(events.Count >= 5, $"Expected at least 5 events, got {events.Count}");
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// H-03
[Fact]
public async Task ConvertUpdatesToEventsAsync_ReasoningClosesOpenMessageAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial")] },
new AgentResponseUpdate { Contents = [new TextReasoningContent("thinking")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.Equal(2, events.OfType<ResponseOutputItemAddedEvent>().Count());
}
// I-02
[Fact]
public async Task ConvertUpdatesToEventsAsync_ErrorContentWithNullMessage_UsesDefaultMessageAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { Contents = [new ErrorContent(null!)] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.Contains(events, e => e is ResponseFailedEvent);
}
// I-03
[Fact]
public async Task ConvertUpdatesToEventsAsync_ErrorContentClosesOpenMessageAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial text")] },
new AgentResponseUpdate { Contents = [new ErrorContent("Something broke")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.True(events.OfType<ResponseOutputItemDoneEvent>().Any());
Assert.IsType<ResponseFailedEvent>(events[^1]);
}
// I-06
[Fact]
public async Task ConvertUpdatesToEventsAsync_ErrorAfterPartialText_ClosesMessageThenFailsAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial text")] },
new AgentResponseUpdate { Contents = [new ErrorContent("Unexpected error")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.True(events.OfType<ResponseOutputItemDoneEvent>().Any());
Assert.IsType<ResponseFailedEvent>(events[^1]);
Assert.DoesNotContain(events, e => e is ResponseCompletedEvent);
}
// J-02
[Fact]
public async Task ConvertUpdatesToEventsAsync_MultipleUsageUpdates_AccumulatesTokensAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Hi")] },
new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 5, TotalTokenCount = 15 })] },
new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 20, OutputTokenCount = 10, TotalTokenCount = 30 })] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.Contains(events, e => e is ResponseCompletedEvent);
}
// J-03
[Fact]
public async Task ConvertUpdatesToEventsAsync_UsageWithZeroTokens_StillCompletesAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
Contents = [new UsageContent(new UsageDetails { InputTokenCount = 0, OutputTokenCount = 0, TotalTokenCount = 0 })]
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.Contains(events, e => e is ResponseCompletedEvent);
}
// K-01
[Fact]
public async Task ConvertUpdatesToEventsAsync_DataContent_IsSkippedWithNoEventsAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { Contents = [new DataContent("data:image/png;base64,aWNv", "image/png")] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.Single(events);
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
// K-02
[Fact]
public async Task ConvertUpdatesToEventsAsync_UriContent_IsSkippedWithNoEventsAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { Contents = [new UriContent("https://example.com/file.txt", "text/plain")] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.Single(events);
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
// K-03
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionResultContent_IsSkippedWithNoEventsAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "result data")] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.Single(events);
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
// L-01
[Fact]
public async Task ConvertUpdatesToEventsAsync_ExecutorInvokedEvent_EmitsWorkflowActionItemAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("executor_1", "invoked") };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.Contains(events, e => e is ResponseOutputItemAddedEvent);
Assert.Contains(events, e => e is ResponseOutputItemDoneEvent);
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// L-02
[Fact]
public async Task ConvertUpdatesToEventsAsync_ExecutorCompletedEvent_EmitsCompletedWorkflowActionAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("executor_1", null) };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.Contains(events, e => e is ResponseOutputItemAddedEvent);
Assert.Contains(events, e => e is ResponseOutputItemDoneEvent);
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// L-03
[Fact]
public async Task ConvertUpdatesToEventsAsync_ExecutorFailedEvent_EmitsFailedWorkflowActionAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { RawRepresentation = new ExecutorFailedEvent("executor_1", new InvalidOperationException("test error")) };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.Contains(events, e => e is ResponseOutputItemAddedEvent);
Assert.Contains(events, e => e is ResponseOutputItemDoneEvent);
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// L-04
[Fact]
public async Task ConvertUpdatesToEventsAsync_WorkflowEventClosesOpenMessageAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("exec_1", "invoked") },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.Equal(2, events.OfType<ResponseOutputItemAddedEvent>().Count());
}
// L-06
[Fact]
public async Task ConvertUpdatesToEventsAsync_InterleavedWorkflowAndTextEventsAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("exec_1", "invoked") },
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Agent says hello")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("exec_1", null) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// M-01
[Fact]
public async Task ConvertUpdatesToEventsAsync_TextThenFunctionCallThenText_ProducesCorrectSequenceAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Let me check...")] },
new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "search", new Dictionary<string, object?> { ["q"] = "weather" })] },
new AgentResponseUpdate { MessageId = "msg_2", Contents = [new MeaiTextContent("Here are the results")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
}
// M-02
[Fact]
public async Task ConvertUpdatesToEventsAsync_ReasoningThenText_ProducesCorrectSequenceAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { Contents = [new TextReasoningContent("Thinking about the answer...")] },
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("The answer is 42")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.Equal(2, events.OfType<ResponseOutputItemAddedEvent>().Count());
}
// M-03
[Fact]
public async Task ConvertUpdatesToEventsAsync_TextThenError_EmitsMessageThenFailedAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Starting...")] },
new AgentResponseUpdate { Contents = [new ErrorContent("Unexpected error")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.IsType<ResponseFailedEvent>(events[^1]);
Assert.DoesNotContain(events, e => e is ResponseCompletedEvent);
Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
}
// M-04
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionCallThenTextThenFunctionCall_ProducesThreeItemsAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "func_a", new Dictionary<string, object?> { ["a"] = 1 })] },
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Processing...")] },
new AgentResponseUpdate { Contents = [new FunctionCallContent("call_2", "func_b", new Dictionary<string, object?> { ["b"] = 2 })] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
}
// ===== Workflow content flow tests (W series) =====
// These simulate the exact update patterns that WorkflowSession.InvokeStageAsync() produces
// when wrapping a Workflow as an AIAgent via AsAIAgent().
// W-01: Multi-executor text output — different MessageIds cause separate messages
[Fact]
public async Task ConvertUpdatesToEventsAsync_MultiExecutorTextOutput_CreatesSeparateMessagesAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
// Executor 1 invoked (RawRepresentation)
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
// Executor 1 produces text (unwrapped AgentResponseUpdateEvent)
new AgentResponseUpdate { MessageId = "msg_agent1", Contents = [new MeaiTextContent("Hello from agent 1")] },
// Executor 1 completed
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
// Executor 2 invoked
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") },
// Executor 2 produces text (different MessageId)
new AgentResponseUpdate { MessageId = "msg_agent2", Contents = [new MeaiTextContent("Hello from agent 2")] },
// Executor 2 completed
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 2 workflow action items (invoked) + 1 text message + 2 workflow action items (completed) + 1 text message = 6 output items
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
// 2 text deltas (one per agent)
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// W-02: Workflow error via ErrorContent (as produced by WorkflowSession for WorkflowErrorEvent)
[Fact]
public async Task ConvertUpdatesToEventsAsync_WorkflowErrorAsContent_EmitsFailedAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Starting work...")] },
// WorkflowErrorEvent is converted to ErrorContent by WorkflowSession
new AgentResponseUpdate { Contents = [new ErrorContent("Workflow execution failed")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Should close the open message, then emit failed
Assert.True(events.OfType<ResponseOutputItemDoneEvent>().Any());
Assert.IsType<ResponseFailedEvent>(events[^1]);
Assert.DoesNotContain(events, e => e is ResponseCompletedEvent);
}
// W-03: Function call from workflow executor (e.g. handoff agent calling transfer_to_agent)
[Fact]
public async Task ConvertUpdatesToEventsAsync_WorkflowFunctionCall_EmitsFunctionCallEventsAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("triage_agent", "start") },
// Agent produces function call (handoff)
new AgentResponseUpdate
{
Contents = [new FunctionCallContent("call_handoff", "transfer_to_code_expert",
new Dictionary<string, object?> { ["reason"] = "User asked about code" })]
},
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("triage_agent", null) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("code_expert", "start") },
new AgentResponseUpdate { MessageId = "msg_expert", Contents = [new MeaiTextContent("Here's how async/await works...")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("code_expert", null) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Should have: 4 workflow actions + 1 function call + 1 text message = 6 output items
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
Assert.Contains(events, e => e is ResponseTextDeltaEvent);
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// W-04: Informational events (superstep, workflow started) are silently skipped
[Fact]
public async Task ConvertUpdatesToEventsAsync_InformationalWorkflowEvents_AreSkippedAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("start") },
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Result")] },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Only one output item (the text message), no workflow action items for informational events
Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
Assert.Contains(events, e => e is ResponseTextDeltaEvent);
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// W-05: Warning events are silently skipped
[Fact]
public async Task ConvertUpdatesToEventsAsync_WorkflowWarningEvent_IsSkippedAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new WorkflowWarningEvent("Agent took too long") },
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Done")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// W-06: Streaming text from multiple workflow turns (same executor, different message IDs)
[Fact]
public async Task ConvertUpdatesToEventsAsync_MultiTurnSameExecutor_CreatesSeparateMessagesAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
new AgentResponseUpdate { MessageId = "msg_turn1", Contents = [new MeaiTextContent("First response")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
// Same executor invoked again (second superstep)
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
new AgentResponseUpdate { MessageId = "msg_turn2", Contents = [new MeaiTextContent("Second response")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 4 workflow action items + 2 text messages = 6 output items
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
}
// W-07: Executor failure mid-stream with partial text
[Fact]
public async Task ConvertUpdatesToEventsAsync_ExecutorFailureAfterPartialText_ClosesMessageAndEmitsFailureAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Starting to process...")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorFailedEvent("agent_1", new InvalidOperationException("Agent crashed")) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Text message should be closed before the failed workflow action item
Assert.True(events.OfType<ResponseOutputItemDoneEvent>().Any());
// Workflow action items: invoked + failed = 2, plus text message = 3
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// W-08: Full handoff pattern — triage → function call → target agent text
[Fact]
public async Task ConvertUpdatesToEventsAsync_FullHandoffPattern_ProducesCorrectEventSequenceAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
// Workflow lifecycle
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("triage", "start") },
// Triage agent decides to hand off
new AgentResponseUpdate
{
Contents = [new FunctionCallContent("call_1", "transfer_to_expert",
new Dictionary<string, object?> { ["reason"] = "technical question" })]
},
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("triage", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
// Next superstep
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("expert", "start") },
// Expert agent responds with text
new AgentResponseUpdate { MessageId = "msg_expert_1", Contents = [new MeaiTextContent("Let me explain...")] },
new AgentResponseUpdate { MessageId = "msg_expert_1", Contents = [new MeaiTextContent(" Here's how it works.")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("expert", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Workflow actions: invoked triage, completed triage, invoked expert, completed expert = 4
// Content items: 1 function call, 1 text message = 2
// Total output items: 6
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
// Two text deltas for the two streaming chunks
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// W-09: SubworkflowErrorEvent treated as informational (error content comes separately)
[Fact]
public async Task ConvertUpdatesToEventsAsync_SubworkflowErrorEvent_IsSkippedAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new SubworkflowErrorEvent("sub_1", new InvalidOperationException("sub failed")) },
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Recovered")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// SubworkflowErrorEvent extends WorkflowErrorEvent which falls through to default skip
Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// W-10: Mixed content types from workflow — reasoning + text
[Fact]
public async Task ConvertUpdatesToEventsAsync_WorkflowReasoningThenText_ProducesCorrectSequenceAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("thinking_agent", "start") },
// Agent produces reasoning content
new AgentResponseUpdate { Contents = [new TextReasoningContent("Analyzing the problem...")] },
// Then text response
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("The answer is 42")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("thinking_agent", null) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Workflow actions: 2 (invoked + completed), reasoning: 1, text message: 1 = 4 output items
Assert.Equal(4, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// W-11: Usage content accumulated across workflow executors
[Fact]
public async Task ConvertUpdatesToEventsAsync_WorkflowUsageAcrossExecutors_AccumulatesCorrectlyAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Response 1")] },
new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 100, OutputTokenCount = 50, TotalTokenCount = 150 })] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") },
new AgentResponseUpdate { MessageId = "msg_2", Contents = [new MeaiTextContent("Response 2")] },
new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 200, OutputTokenCount = 100, TotalTokenCount = 300 })] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Usage should be accumulated in the completed event
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// W-12: Empty workflow — only lifecycle events, no content
[Fact]
public async Task ConvertUpdatesToEventsAsync_EmptyWorkflowOnlyLifecycle_EmitsOnlyCompletedAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("start") },
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Only the terminal completed event
Assert.Single(events);
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)
{
yield return item;
}
await Task.CompletedTask;
}
}