diff --git a/docs/specs/003-dotnet-hosting-channels.md b/docs/specs/003-dotnet-hosting-channels.md new file mode 100644 index 00000000000..44cc5736b38 --- /dev/null +++ b/docs/specs/003-dotnet-hosting-channels.md @@ -0,0 +1,563 @@ +--- +status: proposed +contact: RogerBarreto +date: 2026-05-28 +deciders: RogerBarreto +informed: eavanvalkenburg, agent-framework dotnet contributors +--- + +# .NET minimal hosting core and pluggable channels + +> **Posture: translate, minimal core first.** Scope mirrors [ADR-0027](../decisions/0027-hosting-channels.md) +> ("minimal hosting core and pluggable channels") exactly. The richer cross-channel behaviors (identity +> linking, authorization/allowlists, response targeting beyond the originating channel, push/codecs, +> background + continuation delivery, durable runners, retry/replay, link policy, confidentiality tiers, +> multicast) are **out of scope for v1** and tracked by [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md). +> The Python implementation (`agent-framework-hosting` + `agent-framework-hosting-responses`) is the +> canonical reference; this spec records the idiomatic .NET shape (`IHostApplicationBuilder` + +> `IEndpointRouteBuilder` composition on ASP.NET Core / Generic Host). Where this spec is silent, ADR-0027 +> governs. + +## What are the business goals for this feature? + +Give .NET app authors one low-level hosting surface that exposes a single **hostable target** — an `AIAgent` +or a `Workflow` — on one or more **channels** without writing per-protocol routing or server glue, with +**explicit, debuggable session continuity** via a channel-supplied `ChannelSession(IsolationKey)`. + +The first slice ships the channel-neutral host plus one channel package (Responses) so the host/channel +boundary can be implemented, tested, and explained without designing identity linking or durable delivery +at the same time. + +**Success criteria (mirror ADR-0027 validation gates):** + +- A sample exposes one target over the Responses channel with one `AgentFrameworkHost` and a single + `app.MapAgentFrameworkHost()` call. No hand-written route composition. +- Channel tests prove routes, commands, startup, and shutdown callbacks are contributed by channels and + aggregated by the host. +- Session tests prove identical `ChannelSession.IsolationKey` values resolve to the **same** cached + `AgentSession`, and `ResetSession` rotates that mapping. +- Each channel renders only its own originating response; there is no host-level push, multicast, or + active-channel delivery. +- A workflow sample uses an explicit checkpoint location. + +## What is the problem being solved? + +Every protocol surface today is its own package with its own `Map*` extension. A developer exposing one +agent over two protocols stands up two hosts and stitches lifecycle, routing, and session handling by hand. +The gap is between **owning a hostable target** and **operationalizing it on a channel**: Agent Framework +already provides agents, workflows, sessions, run inputs, streaming, and the `AIAgent` / `Workflow` +execution seams. What is missing is a small channel-neutral host that owns route/lifecycle aggregation, +target invocation, `IsolationKey -> AgentSession` resolution + caching, per-channel hooks, and workflow +checkpoint wiring — and leaves protocol shape inside channel packages. + +## Decisions + +1. **Posture: minimal core, translate.** Scope = ADR-0027. ADR-0028 capabilities are deferred and must not + appear in the v1 public surface. +2. **Composition: builder-centric.** `builder.AddAgentFrameworkHost(target).AddXxxChannel(...)` then + `app.MapAgentFrameworkHost()`. +3. **Channel contract: `abstract class Channel` + hook interfaces.** `Channel` exposes `Name`, `Path`, + `ConfigureServices`, `Contribute`. `ChannelContribution` (a plain mutable `sealed class`) carries routes, + endpoint filters (middleware), commands, and startup/shutdown callbacks. Optional per-channel hooks + (`IChannelRunHook`, `IChannelResponseHook`, `IChannelStreamTransformHook`) are small separate interfaces. +4. **Channel lifecycle: two-phase.** `ConfigureServices(IServiceCollection)` at `AddXxxChannel` time; + `Contribute(ChannelContext)` at `MapAgentFrameworkHost` time. Matches the ASP.NET Core + `ConfigureServices` + `Configure` split. +5. **Target runner: swappable `IHostedTargetRunner`.** `AIAgentRunner` for `AIAgent`, `WorkflowRunner` for + `Workflow`. Channels never branch on target type. (A Foundry hosted-agent runner is a later, additive + package — not part of this slice.) +6. **Session continuity: explicit `ChannelSession(IsolationKey)`.** The host treats `IsolationKey` as an + opaque **session partition key**, not proof of identity. Channels / host middleware must authenticate and + authorize any externally supplied value before passing it to the host. Two channels share history only + when they produce the same `IsolationKey`. +7. **`ChannelIdentity` is request metadata only.** In v1 it is **not** a linking, authorization, or delivery + key. (Those uses are ADR-0028.) +8. **Host state store: new `IHostStateStore`, limited.** Owns only reset-session aliases and workflow + checkpoint path derivation. It does **not** store linked identities, active-channel state, response + routing, continuation records, durable runner queues, or delivery attempts (all ADR-0028). Ships + `InMemoryHostStateStore` and `FileHostStateStore`. +9. **Hosting target: Generic Host + ASP.NET Core.** `AddAgentFrameworkHost(this IHostApplicationBuilder, target)` + accepts both `WebApplicationBuilder` and `HostApplicationBuilder`. HTTP routes via + `app.MapAgentFrameworkHost(this IEndpointRouteBuilder)`. Non-HTTP channels auto-start via `IHostedService`. +10. **Naming: literal port.** Host type `AgentFrameworkHost`; extensions `AddAgentFrameworkHost(...)` / + `MapAgentFrameworkHost(...)`; channel-add extensions `AddResponsesChannel(...)`. +11. **Isolation context propagation: `IsolationKeys.Current` (AsyncLocal) + DI `IIsolationKeysAccessor`,** + lifted from `x-agent-user-isolation-key` / `x-agent-chat-isolation-key` by a host-applied endpoint filter + **only when the Foundry hosting environment flag (`FOUNDRY_HOSTING_ENVIRONMENT`) is present**. Distinct + from the app-level `ChannelSession.IsolationKey`. Mirrors the Python host's Foundry isolation middleware; + reusing the header names does not make this the supported Foundry Hosted Agents surface. +12. **Workflow channel surface: workflow-agnostic channels.** Carried by `WorkflowRunner : IHostedTargetRunner`, + generic `HostedRunResult`, free-form `ChannelRequest.Attributes` (reserved key + `workflow.checkpoint_id` for caller-supplied checkpoint resume), and workflow checkpoint storage on + `WorkflowBuilder`. The host does not own a continuation store in v1. +13. **v1 scope: net-new only.** Existing `Hosting.OpenAI` / `Hosting.A2A*` / `Hosting.AGUI.AspNetCore` / + `Hosting.AzureFunctions` / `Foundry.Hosting` `Map*` extensions stay untouched. + +## Package layout + +### v1 NuGet packages (new) + +``` +Microsoft.Agents.AI.Hosting.Channels (core) +├── AgentFrameworkHost +├── AgentFrameworkHostOptions (StatePaths) +├── IAgentFrameworkHostBuilder +├── HostApplicationBuilderHostingChannelsExtensions (AddAgentFrameworkHost on IHostApplicationBuilder) +├── EndpointRouteBuilderHostingChannelsExtensions (MapAgentFrameworkHost on IEndpointRouteBuilder) +├── Channel (abstract class) +├── ChannelContribution (sealed class; mutable get/set) +├── ChannelCommand (Name / Description / Handler) +├── ChannelCommandContext (Request / Reply callback) +├── ChannelRequest (sealed class; ctor Channel/Operation/Input + copy ctor) +├── ChannelSession (sealed class; Key / ConversationId / IsolationKey nullable + copy ctor) +├── SessionMode (enum: Auto / Required / Disabled) +├── ChannelIdentity (sealed class; ctor Channel/NativeId; request metadata only) +├── HostedRunResult (abstract class; non-generic base) +├── HostedRunResult (sealed class; generic envelope) +├── HostedStreamItem (abstract class; Update / Event / Completed) +├── ChannelContext (sealed class; host-constructed run/stream surface) +├── IChannelRunHook + ChannelRunHookContext +├── IChannelResponseHook + ChannelResponseContext +├── IChannelStreamTransformHook +├── IHostedTargetRunner +│ ├── AIAgentRunner +│ └── WorkflowRunner +├── WorkflowRunResult (Completed / AwaitingInput / Failed) +├── IHostStateStore (reset-session aliases + checkpoint path only) +├── InMemoryHostStateStore +├── FileHostStateStore +├── HostStatePathOptions (Root / Aliases / Checkpoints) +├── IsolationKeys (sealed class; Current AsyncLocal + Foundry header consts) +└── IIsolationKeysAccessor (DI accessor; internal Foundry-flag-gated lift filter) + +Microsoft.Agents.AI.Hosting.Channels.Responses +├── ResponsesChannel +├── ResponsesChannelOptions (Path / RunHook / ResponseHook / StreamTransformHook) +└── AgentFrameworkHostBuilderResponsesExtensions (AddResponsesChannel) +``` + +### v1 NuGet packages (untouched) + +`Microsoft.Agents.AI.Hosting.OpenAI`, `.Hosting.A2A`, `.Hosting.A2A.AspNetCore`, `.Hosting.AGUI.AspNetCore`, +`.Hosting.AzureFunctions`, `.Foundry.Hosting`. The Responses channel reuses Responses models / parsing from +`Microsoft.Agents.AI.Hosting.OpenAI` where practical rather than reimplementing the wire format. + +## API changes + +> Draft signatures; nullability and `Experimental` attributes sharpen during implementation. Every public +> type ships the standard copyright header and is `[Experimental(...)]` for the v1 release. +> +> **Namespace convention.** Public types live in `Microsoft.Agents.AI.Hosting.Channels` (and `*.Responses`). +> `IHostApplicationBuilder` extensions live in `namespace Microsoft.Extensions.Hosting`; `IEndpointRouteBuilder` +> extensions in `namespace Microsoft.AspNetCore.Builder`; channel-add extensions in +> `Microsoft.Agents.AI.Hosting.Channels`. + +### Host + builder + +```csharp +namespace Microsoft.Agents.AI.Hosting.Channels; + +public sealed class AgentFrameworkHost +{ + public IServiceProvider Services { get; } + public IReadOnlyList Channels { get; } + public IHostedTargetRunner TargetRunner { get; } + public IHostStateStore StateStore { get; } + public AgentFrameworkHostOptions Options { get; } + + public ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default); + public IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default); + + // Rotates the active session-id alias for an isolation key (host-tracked channels' /new-style commands). + public ValueTask ResetSessionAsync(string isolationKey, CancellationToken cancellationToken = default); +} + +public sealed class AgentFrameworkHostOptions +{ + public HostStatePathOptions? StatePaths { get; set; } +} +``` + +```csharp +namespace Microsoft.Extensions.Hosting; + +public static class HostApplicationBuilderHostingChannelsExtensions +{ + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, AIAgent target, Action? configure = null); + + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, Workflow target, Action? configure = null); + + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, Func targetFactory, + Action? configure = null) where TTarget : class; +} +``` + +```csharp +namespace Microsoft.AspNetCore.Builder; + +public static class EndpointRouteBuilderHostingChannelsExtensions +{ + public static IEndpointConventionBuilder MapAgentFrameworkHost(this IEndpointRouteBuilder endpoints); +} +``` + +```csharp +namespace Microsoft.Agents.AI.Hosting.Channels; + +public interface IAgentFrameworkHostBuilder +{ + IServiceCollection Services { get; } + AgentFrameworkHostOptions Options { get; } + + IAgentFrameworkHostBuilder AddChannel(Channel channel); + IAgentFrameworkHostBuilder AddChannel(Func factory) where TChannel : Channel; + + IAgentFrameworkHostBuilder UseHostStateStore() where TStore : class, IHostStateStore; +} +``` + +### Channel contract + +```csharp +public abstract class Channel +{ + public abstract string Name { get; } + public virtual string Path => string.Empty; // host wraps Routes in endpoints.MapGroup(Path) + public virtual void ConfigureServices(IServiceCollection services) { } + public abstract ChannelContribution Contribute(ChannelContext context); +} + +public sealed class ChannelContribution +{ + public IReadOnlyList> Routes { get; set; } = []; + public IReadOnlyList EndpointFilters { get; set; } = []; // host-level middleware + public IReadOnlyList Commands { get; set; } = []; + public Func? OnStartup { get; set; } + public Func? OnShutdown { get; set; } +} + +// Host-owned, channel-consumed: a sealed class with an internal constructor, not an interface +// (channels only consume it, so the interface added nothing). +public sealed class ChannelContext +{ + internal ChannelContext(IServiceProvider services, AgentFrameworkHost host); + + public IServiceProvider Services { get; } + public AgentFrameworkHost Host { get; } + public IHostStateStore StateStore { get; } + + public ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default); + public IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default); +} +``` + +> Hooks are discovered by the host: a channel implements `IChannelRunHook` / `IChannelResponseHook` / +> `IChannelStreamTransformHook` directly, or routes app-supplied hooks through its options object +> (`ResponsesChannelOptions.RunHook`, etc.). `ChannelRunHook` runs after channel parsing and before target +> invocation; `ChannelResponseHook` runs after invocation and before the originating channel serializes; +> `ChannelStreamTransformHook` is applied by the host while the channel consumes streamed updates. + +### Channel-neutral request envelope + +```csharp +public sealed class ChannelRequest +{ + public ChannelRequest(string channel, string operation, object input); // required members -> ctor params + public ChannelRequest(ChannelRequest other); // copy constructor (replaces `with`) + + public string Channel { get; } // get-only + public string Operation { get; } // get-only ("message.create", "command.invoke", ...) + public object Input { get; set; } // string / ChatMessage / IEnumerable / workflow input + public ChannelSession? Session { get; set; } + public ChannelIdentity? Identity { get; set; } // request metadata only + public string? ConversationId { get; set; } + public ChatOptions? Options { get; set; } + public SessionMode SessionMode { get; set; } = SessionMode.Auto; + public IReadOnlyDictionary Metadata { get; set; } = ImmutableDictionary.Empty; + public IReadOnlyDictionary Attributes { get; set; } = ImmutableDictionary.Empty; + public bool Stream { get; set; } +} + +public sealed class ChannelSession +{ + public ChannelSession(); + public ChannelSession(ChannelSession other); // copy constructor (replaces `with`) + + public string? Key { get; set; } // caller-supplied (previous_response_id, ...) + public string? ConversationId { get; set; } + public string? IsolationKey { get; set; } // opaque session partition key + public IReadOnlyDictionary Attributes { get; set; } = ImmutableDictionary.Empty; +} + +public sealed class ChannelIdentity +{ + public ChannelIdentity(string channel, string nativeId); + + public string Channel { get; } // get-only + public string NativeId { get; } // get-only (the USER id, never the chat/conversation id) + public IReadOnlyDictionary Attributes { get; set; } = ImmutableDictionary.Empty; +} + +public enum SessionMode { Auto, Required, Disabled } +``` + +### Results + streaming + +```csharp +public abstract class HostedRunResult +{ + public ChannelSession? Session { get; set; } + public abstract object? ResultObject { get; } +} + +public sealed class HostedRunResult : HostedRunResult +{ + public HostedRunResult(TResult result); + public TResult Result { get; } // get-only + public override object? ResultObject => this.Result; + public HostedRunResult Replace(TNew newResult) => new(newResult) { Session = this.Session }; +} + +public abstract class HostedStreamItem { private protected HostedStreamItem() { } } + +public sealed class HostedStreamUpdate : HostedStreamItem // normalized agent stream +{ + public HostedStreamUpdate(AgentResponseUpdate update); + public AgentResponseUpdate Update { get; } +} + +public sealed class HostedStreamEvent : HostedStreamItem // protocol/workflow events +{ + public HostedStreamEvent(object @event); + public object Event { get; } +} + +public sealed class HostedStreamCompleted : HostedStreamItem // terminal +{ + public HostedStreamCompleted(HostedRunResult result); + public HostedRunResult Result { get; } +} +``` + +### Host state store (limited) + +```csharp +public interface IHostStateStore +{ + // Session-alias rotation backing ResetSessionAsync (host-tracked channels' /new). + ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken cancellationToken); + ValueTask GetActiveSessionAliasAsync(string isolationKey, CancellationToken cancellationToken); + + // Persistent workflow checkpoint location for an isolation key, or null when this store does not persist + // checkpoints (e.g. the in-memory store). Implementations reject path-traversal patterns in the key. + ValueTask GetCheckpointLocationAsync(string isolationKey, CancellationToken cancellationToken); +} + +public sealed class HostStatePathOptions +{ + public string? Root { get; set; } // shorthand: derives subpaths if unset + public string? AliasesPath { get; set; } // reset-session aliases + public string? CheckpointsPath { get; set; } // workflow checkpoint derivation root +} +``` + +> `InMemoryHostStateStore` (default) returns `null` from `GetCheckpointLocationAsync` (no persistent +> checkpoints). `FileHostStateStore` is `IDisposable`: it takes an exclusive single-owner OS lock on its root +> directory (a second store over the same directory fails fast), validates the isolation key against a +> path-traversal denylist (no separators/NUL, not dot-only/absolute/drive-rooted), and returns a per-key +> checkpoint directory. There is no continuation store, link grant, last-seen ledger, or identity registry +> in v1. + +### Hosted target runner + +```csharp +public interface IHostedTargetRunner +{ + ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken); + IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken); +} + +public sealed class AIAgentRunner(AIAgent agent, IHostStateStore stateStore) : IHostedTargetRunner { /* ... */ } +public sealed class WorkflowRunner(Workflow workflow, IHostStateStore? stateStore = null) : IHostedTargetRunner { /* ... */ } +``` + +`AIAgentRunner` resolves an `AgentSession` per isolation key, enforces `SessionMode.Required`, and forwards +`ChannelRequest.Options` (wrapped in `ChatClientAgentRunOptions`). `WorkflowRunner` drives +`InProcessExecution.OpenStreamingAsync`, sends the input by its runtime type plus a `TurnToken` (so agent-based +workflows take a turn), collects terminal `WorkflowOutputEvent`s (excluding streaming `AgentResponseUpdateEvent` +deltas), and pauses on `RequestInfoEvent` into `WorkflowRunResult { Status = AwaitingInput }`. When the state +store yields a persistent checkpoint location for the request's isolation key, the run is checkpointed there +(via a `FileSystemJsonCheckpointStore` + `CheckpointManager`); the committed checkpoint id is surfaced on the +result session attributes (`"workflow.checkpoint_id"`), and a request carrying that id on +`ChannelRequest.Attributes` resumes from it (rehydrate, then apply the new input). + +### Isolation keys + +```csharp +public sealed class IsolationKeys +{ + public IsolationKeys(string? userKey, string? chatKey); + + public string? UserKey { get; } // get-only + public string? ChatKey { get; } // get-only + public bool IsEmpty => this.UserKey is null && this.ChatKey is null; + + public static IsolationKeys? Current { get; set; } // AsyncLocal-backed, scoped to the request flow + + public const string UserHeader = "x-agent-user-isolation-key"; + public const string ChatHeader = "x-agent-chat-isolation-key"; +} + +public interface IIsolationKeysAccessor { IsolationKeys? Current { get; } } +``` + +An internal `IsolationKeysEndpointFilter` lifts the two headers into `IsolationKeys.Current` for the request +and resets it afterwards. `MapAgentFrameworkHost` applies the filter to every channel route **only when +`FOUNDRY_HOSTING_ENVIRONMENT` is set**; absent the flag (or absent both headers) the request passes through and +`Current` stays `null`. `AddAgentFrameworkHost` registers `IIsolationKeysAccessor` so Foundry-aware providers +read the keys without each channel parsing headers. Mirrors the Python host's `_FoundryIsolationASGIMiddleware`. + +## Responses channel + +`Microsoft.Agents.AI.Hosting.Channels.Responses` maps OpenAI Responses-shaped requests and streams onto the +host and renders Responses-compatible output, reusing the Responses models / converters / streaming +generators from `Microsoft.Agents.AI.Hosting.OpenAI` where practical. + +```csharp +public sealed class ResponsesChannel : Channel +{ + public ResponsesChannel(ResponsesChannelOptions options); + public override string Name => "responses"; + public override string Path => this._options.Path; // default "/responses" + public override ChannelContribution Contribute(ChannelContext context); // POST {Path} +} + +public sealed class ResponsesChannelOptions +{ + public string Path { get; set; } = "/responses"; // "" mounts at the app root + public IChannelRunHook? RunHook { get; set; } + public IChannelResponseHook? ResponseHook { get; set; } + public IChannelStreamTransformHook? StreamTransformHook { get; set; } +} +``` + +- A Responses request maps to a `ChannelRequest` (`Operation = "message.create"`, `Input` = parsed input + items incl. `input_text` / `input_image` / `input_file` / hosted-file content, generation options remapped + to `ChatOptions` and stripped by the default run hook, identity from `safety_identifier`/`user` — a + non-string identifier is ignored rather than rejected, `Session.IsolationKey` = `previous_response_id` -> + Foundry chat key -> minted `response_id`). Malformed input is rejected with HTTP 422. +- The originating Responses response is rendered by the channel (`ResponsesOutputRenderer`) as typed output + items: assistant text coalesced into `message` items, `reasoning`, `function_call`, `function_call_output` + (with media results projected into `input_text` / `input_image` / `input_file` parts), `mcp_call`, + `code_interpreter_call`, `image_generation_call`, and `mcp_approval_request` / `mcp_approval_response`. + Adjacent call/result content for the same id coalesces into a single item (across messages too), and a + provider raw item carried on `AIContent.RawRepresentation` is passed through verbatim, with a later raw item + of the same `(type, id)` replacing an earlier partial. Workflow outputs (messages, agent responses, or bare + content) project through the same renderer. Streaming emits the richer SSE sequence — `response.created`, + `response.output_item.added`, `response.content_part.added`, `response.output_text.delta`, + `response.output_text.done`, `response.content_part.done`, `response.output_item.done`, `response.completed` + — and additionally emits `response.reasoning_text.delta`/`.done` and + `response.function_call_arguments.delta`/`.done` plus dedicated output items for non-text streamed content; + the completed envelope is rendered from the accumulated streamed updates when no final agent response is + available. It applies the `IChannelResponseHook` to the final streamed result, and on a mid-stream error + emits a `response.failed` event (not a post-headers JSON error). The host applies + `IChannelStreamTransformHook` as updates are consumed. The deferred SSE stream runs under the request's + parent trace span. +- For a `Workflow` target, the run hook prepares typed workflow input; agent-based workflows are driven with a + `TurnToken` so their replies render, and the channel renders `RequestInfoEvent` as the protocol's + awaiting-input shape. Resume is caller-driven via the surfaced checkpoint id: the runner rehydrates from the + checkpoint and then applies the new input so the resumed run advances. + +## E2E code samples + +### Sample 1: Responses agent on one host + +```csharp +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.Hosting; +using OpenAI.Chat; + +var builder = WebApplication.CreateBuilder(args); + +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deployment) + .AsAIAgent(name: "Concierge", instructions: "You are a helpful concierge."); + +builder.AddAgentFrameworkHost(agent) + .AddResponsesChannel(); + +var app = builder.Build(); +app.MapAgentFrameworkHost(); +app.Run(); +``` + +### Sample 2: Responses-hosted workflow with run-hook input prep + checkpoints + +```csharp +var workflow = new WorkflowBuilder(checkpointStorage: new FileCheckpointStorage("./.checkpoints")) + .AddExecutor(/* intake */) + .Build(); + +builder.AddAgentFrameworkHost(workflow, o => o.StatePaths = new HostStatePathOptions { Root = "./.afhost" }) + .AddResponsesChannel(o => o.RunHook = new MyWorkflowInputRunHook()); + +var app = builder.Build(); +app.MapAgentFrameworkHost(); +app.Run(); +``` + +The run hook turns the parsed Responses input into the workflow's typed input. If the workflow pauses on a +`RequestInfoEvent`, the channel renders an awaiting-input response; the caller resumes by re-invoking with +`attributes["workflow.checkpoint_id"]`. + +## Test strategy + +| Layer | Test type | Proves | +|---|---|---| +| Channel contract | Unit | `Contribute` returns routes/commands/lifecycle; host aggregates them under `MapGroup(Path)`. | +| Host composition | Unit | `AddAgentFrameworkHost` + `AddResponsesChannel` produces a host whose `Channels` list matches; `ConfigureServices` runs pre-Build, `Contribute` post-Build. | +| Session continuity | Unit | Identical `IsolationKey` resolves to the same cached `AgentSession`; `ResetSessionAsync` rotates the alias. | +| `ResponsesChannel` | Integration (`TestServer`) | Responses request round-trips the full `ChatMessage` content list (no lossy collapse); SSE stream renders. | +| Workflow path | Integration | `RequestInfoEvent` renders awaiting-input; re-invoke with `workflow.checkpoint_id` resumes. | +| Isolation lift | Unit + Integration | `IsolationKeys.Current` binds from the two headers under the Foundry flag and resets per request; ignored without the flag; empty header treated as absent; concurrent requests stay isolated. | + +## Phasing + +1. **Core** — `Microsoft.Agents.AI.Hosting.Channels`: every type in the core layout. `InMemoryHostStateStore`, + `FileHostStateStore`, `AIAgentRunner`, `WorkflowRunner`. Unit tests per type. +2. **Responses channel** — `Microsoft.Agents.AI.Hosting.Channels.Responses` reusing Hosting.OpenAI Responses + models. Integration tests for agent + workflow targets. +3. **Samples + docs** — the two samples above. README per package. + +## Non-goals for v1 (deferred to ADR-0028) + +Deliberately **not** part of this contract; tracked by +[ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md): + +- cross-channel identity linking (`IIdentityLinker`, one-time-code / Entra linkers), +- identity allowlists / authorization policy (`IIdentityAllowlist`, `AuthorizationProfile`), +- response routing beyond the originating channel (`ResponseTarget`, active channel, all-linked), +- push or payload codecs (`IChannelPush`, `IChannelPushCodec`), +- background runs + continuation tokens, +- durable task runners (`IDurableTaskRunner`, in-process runner), +- retry / replay (`RetryPolicy`), +- fan-out / multicast / all-linked delivery, +- confidentiality tiers and `ILinkPolicy`, +- multi-user conversation scoping / group addressing (`ConversationScope`, `AcceptInGroup`), +- additional channel packages (Invocations, Telegram, Discord, Activity Protocol), +- a host-level multi-agent router. + +These are follow-up enhancements, not prerequisites for shipping or using the v1 host. + +## References + +- .NET ADRs (scope): [`0027-hosting-channels.md`](../decisions/0027-hosting-channels.md) (v1 core), + [`0028-hosting-linking-multicast-enhancements.md`](../decisions/0028-hosting-linking-multicast-enhancements.md) (deferred). +- Python reference impl: PR microsoft/agent-framework#6580 (`agent-framework-hosting` + `agent-framework-hosting-responses`). +- Python spec: [`002-python-hosting-channels.md`](./002-python-hosting-channels.md). +- Existing .NET hosting packages this work coexists with: `Microsoft.Agents.AI.Hosting.OpenAI`, + `Microsoft.Agents.AI.Hosting.A2A(.AspNetCore)`, `Microsoft.Agents.AI.Hosting.AGUI.AspNetCore`, + `Microsoft.Agents.AI.Hosting.AzureFunctions`, `Microsoft.Agents.AI.Foundry.Hosting`. \ No newline at end of file diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 06c45d70ffe..c0e565def4e 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -378,6 +378,10 @@ + + + + @@ -620,6 +624,8 @@ + + @@ -674,6 +680,8 @@ + + diff --git a/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/01_ResponsesAgent.csproj b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/01_ResponsesAgent.csproj new file mode 100644 index 00000000000..eb9d36b0ba9 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/01_ResponsesAgent.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + Exe + enable + enable + ResponsesAgentSample + ResponsesAgentSample + $(NoWarn);MAAI001;OPENAI001;CA1303 + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/Program.cs b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/Program.cs new file mode 100644 index 00000000000..52cd9f2cdd8 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Exposes an AIAgent on the OpenAI Responses-shaped channel. +// POST /responses { "input": "Hi" } -> Responses JSON +// POST /responses { "input": "Hi", "stream": true } -> SSE stream +// Session continuity is keyed by ChannelSession.IsolationKey; identical keys resolve to the same +// cached AgentSession. This sample lets the channel derive the session from previous_response_id. + +#pragma warning disable CA1031 + +using System.ClientModel; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; +using OpenAI; +using OpenAI.Chat; + +var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") + ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); +var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5.4-mini"; + +// The hosting channel only needs an OpenAI-compatible chat client, so it depends on the OpenAI SDK +// directly rather than Azure.AI.OpenAI. No Azure-specific features are required here. +AIAgent agent = new OpenAIClient(new ApiKeyCredential(apiKey)) + .GetChatClient(model) + .AsAIAgent(name: "Concierge", instructions: "You are a helpful concierge."); + +var builder = WebApplication.CreateBuilder(args); + +builder.AddAgentFrameworkHost(agent) + .AddResponsesChannel(); + +var app = builder.Build(); +app.MapAgentFrameworkHost(); +app.Run(); \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/README.md b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/README.md new file mode 100644 index 00000000000..0952d5b9945 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/README.md @@ -0,0 +1,34 @@ +# Responses agent + +Exposes an `AIAgent` on the OpenAI Responses-shaped channel from +`Microsoft.Agents.AI.Hosting.Channels.Responses`. The smallest demonstration of +`AddAgentFrameworkHost(agent).AddResponsesChannel()` + `MapAgentFrameworkHost()`. + +## What it shows + +* One `AgentFrameworkHost` owning a single Responses channel +* `POST /responses` returning a Responses JSON object +* `POST /responses` with `"stream": true` returning a Server-Sent-Events stream +* Session continuity keyed by `ChannelSession.IsolationKey` (here derived from `previous_response_id`) + +## Requirements + +* `OPENAI_API_KEY` set +* `OPENAI_MODEL` optional; defaults to `gpt-5.4-mini` + +## Try it + +```bash +cd dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent +dotnet run +``` + +```bash +curl -X POST http://localhost:5000/responses \ + -H "Content-Type: application/json" \ + -d '{ "input": "Tell me a short joke." }' + +curl -N -X POST http://localhost:5000/responses \ + -H "Content-Type: application/json" \ + -d '{ "input": "Outline a haiku about spring.", "stream": true }' +``` \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/02_ResponsesWorkflow.csproj b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/02_ResponsesWorkflow.csproj new file mode 100644 index 00000000000..7bd287e6283 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/02_ResponsesWorkflow.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + Exe + enable + enable + ResponsesWorkflowSample + ResponsesWorkflowSample + $(NoWarn);MAAI001;OPENAI001;CA1303 + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/EchoExecutor.cs b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/EchoExecutor.cs new file mode 100644 index 00000000000..38b55d27f7c --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/EchoExecutor.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace ResponsesWorkflowSample; + +/// Minimal single-executor workflow body: echoes the input string as the workflow output. +internal sealed class EchoExecutor() : Executor("EchoExecutor") +{ + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + ValueTask.FromResult($"echo: {message}"); +} \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/Program.cs b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/Program.cs new file mode 100644 index 00000000000..93d6d710cae --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/Program.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Exposes a Workflow on the OpenAI Responses-shaped channel. A run hook turns the parsed Responses +// input (ChatMessage list) into the workflow's typed string input. Workflow checkpoint storage lives on +// the workflow; the host derives a per-isolation-key checkpoint location from StatePaths. + +#pragma warning disable CA1031 + +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Agents.AI.Workflows; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; +using ResponsesWorkflowSample; + +var builder = WebApplication.CreateBuilder(args); + +// Application-defined single-executor workflow that echoes its input. +var echo = new EchoExecutor(); +var workflow = new WorkflowBuilder(echo).WithOutputFrom(echo).Build(); + +builder.AddAgentFrameworkHost(workflow, o => o.StatePaths = new HostStatePathOptions { Root = "./.afhost" }) + .AddResponsesChannel(o => o.RunHook = new WorkflowInputRunHook()); + +var app = builder.Build(); +app.MapAgentFrameworkHost(); +app.Run(); \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/README.md b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/README.md new file mode 100644 index 00000000000..4d2c77aa5ef --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/README.md @@ -0,0 +1,28 @@ +# Responses-hosted workflow + +Exposes a `Workflow` on the OpenAI Responses-shaped channel. A run hook adapts the channel's parsed +Responses input (a `ChatMessage` list) into the workflow's typed string input before the host invokes the +workflow. + +## What it shows + +* `AddAgentFrameworkHost(workflow).AddResponsesChannel(o => o.RunHook = ...)` +* The run-hook seam (`IChannelRunHook`) for workflow input preparation +* Host `StatePaths` for per-isolation-key workflow checkpoint location derivation + +## Requirements + +No model credentials required; the workflow echoes its input. + +## Try it + +```bash +cd dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow +dotnet run +``` + +```bash +curl -X POST http://localhost:5000/responses \ + -H "Content-Type: application/json" \ + -d '{ "input": "ping" }' +``` \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/WorkflowInputRunHook.cs b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/WorkflowInputRunHook.cs new file mode 100644 index 00000000000..c47af04c4c8 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/WorkflowInputRunHook.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.Extensions.AI; + +namespace ResponsesWorkflowSample; + +/// +/// Run hook that adapts the Responses channel's parsed input (a list) into the +/// workflow's typed string input before the host invokes the . +/// +internal sealed class WorkflowInputRunHook : IChannelRunHook +{ + public ValueTask OnRequestAsync(ChannelRequest request, ChannelRunHookContext context, CancellationToken cancellationToken) + { + var text = request.Input switch + { + string s => s, + IEnumerable messages => string.Join("\n", messages.Select(m => m.Text)), + _ => request.Input.ToString() ?? string.Empty, + }; + + return new(new ChannelRequest(request) { Input = text }); + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/AgentFrameworkHostBuilderResponsesExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/AgentFrameworkHostBuilderResponsesExtensions.cs new file mode 100644 index 00000000000..fd5d136106a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/AgentFrameworkHostBuilderResponsesExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// +/// Extensions on for the OpenAI Responses channel. +/// +public static class AgentFrameworkHostBuilderResponsesExtensions +{ + /// Add the OpenAI Responses-shaped channel. + public static IAgentFrameworkHostBuilder AddResponsesChannel( + this IAgentFrameworkHostBuilder builder, + Action? configure = null) + { + Throw.IfNull(builder); + var options = new ResponsesChannelOptions(); + configure?.Invoke(options); + return builder.AddChannel(new ResponsesChannel(options)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/LenientStringConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/LenientStringConverter.cs new file mode 100644 index 00000000000..2533b510216 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/LenientStringConverter.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// +/// Reads a JSON value as a string, returning for any non-string token instead of +/// throwing. Used for the optional safety_identifier / user identity fields so a malformed +/// (e.g. numeric) value is ignored, matching Python's parse_responses_identity which yields no identity +/// for non-string values rather than rejecting the request. +/// +internal sealed class LenientStringConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return reader.GetString(); + } + + if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) + { + reader.Skip(); + } + + return null; + } + + public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) => + writer.WriteStringValue(value); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesJsonContext.cs new file mode 100644 index 00000000000..76c4009e7d1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesJsonContext.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(ResponsesRequestModel))] +[JsonSerializable(typeof(ResponsesResponseModel))] +[JsonSerializable(typeof(ResponsesStreamResponseEvent))] +[JsonSerializable(typeof(ResponsesStreamOutputItemEvent))] +[JsonSerializable(typeof(ResponsesStreamContentPartEvent))] +[JsonSerializable(typeof(ResponsesStreamTextDeltaEvent))] +[JsonSerializable(typeof(ResponsesStreamTextDoneEvent))] +[JsonSerializable(typeof(ResponsesStreamReasoningTextDeltaEvent))] +[JsonSerializable(typeof(ResponsesStreamReasoningTextDoneEvent))] +[JsonSerializable(typeof(ResponsesStreamFunctionCallArgumentsDeltaEvent))] +[JsonSerializable(typeof(ResponsesStreamFunctionCallArgumentsDoneEvent))] +[JsonSerializable(typeof(ResponsesErrorModel))] +[JsonSerializable(typeof(System.Text.Json.JsonElement))] +[JsonSerializable(typeof(System.Text.Json.Nodes.JsonNode))] +internal sealed partial class ResponsesJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesModels.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesModels.cs new file mode 100644 index 00000000000..68a4b75cbc1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesModels.cs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// Inbound OpenAI Responses request body (subset). +internal sealed class ResponsesRequestModel +{ + [JsonPropertyName("input")] public JsonElement? Input { get; set; } + [JsonPropertyName("stream")] public bool Stream { get; set; } + [JsonPropertyName("previous_response_id")] public string? PreviousResponseId { get; set; } + [JsonPropertyName("model")] public string? Model { get; set; } + [JsonPropertyName("instructions")] public string? Instructions { get; set; } + [JsonPropertyName("metadata")] public Dictionary? Metadata { get; set; } + + // Generation-control fields remapped onto ChatOptions (stripped by the default run hook). + [JsonPropertyName("temperature")] public double? Temperature { get; set; } + [JsonPropertyName("top_p")] public double? TopP { get; set; } + [JsonPropertyName("max_output_tokens")] public int? MaxOutputTokens { get; set; } + [JsonPropertyName("parallel_tool_calls")] public bool? ParallelToolCalls { get; set; } + + // Caller identity: OpenAI Responses replaced `user` with `safety_identifier`. A non-string value is + // tolerated and ignored (identity becomes null) rather than rejecting the request. + [JsonPropertyName("safety_identifier")][JsonConverter(typeof(LenientStringConverter))] public string? SafetyIdentifier { get; set; } + [JsonPropertyName("user")][JsonConverter(typeof(LenientStringConverter))] public string? User { get; set; } +} + +/// Outbound Responses object (non-streaming + terminal stream payload). +internal sealed class ResponsesResponseModel +{ + [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; + [JsonPropertyName("object")] public string Object { get; set; } = "response"; + [JsonPropertyName("created_at")] public long CreatedAt { get; set; } + [JsonPropertyName("status")] public string Status { get; set; } = "completed"; + [JsonPropertyName("model")] public string? Model { get; set; } + [JsonPropertyName("output")] public List Output { get; set; } = []; + [JsonPropertyName("usage")] public ResponsesUsageModel? Usage { get; set; } + [JsonPropertyName("error")] public ResponsesErrorBody? Error { get; set; } +} + +/// +/// Unified Responses output item. The type discriminates which fields apply: message +/// (role/content), function_call (call_id/name/arguments), function_call_output +/// (call_id/output), reasoning (summary/content), mcp_call (server_label/name/arguments/output), +/// code_interpreter_call (code/container_id/outputs), image_generation_call (result), +/// mcp_approval_request (server_label/name/arguments) or mcp_approval_response +/// (approval_request_id/approve). Unset fields are omitted when serialized. Shape-varying fields +/// (content, output, summary, outputs) are modeled as so a +/// single item type can carry the exact OpenAI Responses shape for each content kind. +/// +[JsonConverter(typeof(ResponsesOutputItemConverter))] +internal sealed class ResponsesOutputItem +{ + [JsonPropertyName("type")] public string Type { get; set; } = "message"; + [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; + [JsonPropertyName("status")] public string? Status { get; set; } + + // message (output_text[]) / reasoning (reasoning_text[]) + [JsonPropertyName("role")] public string? Role { get; set; } + [JsonPropertyName("content")] public JsonNode? Content { get; set; } + + // function_call / function_call_output / mcp_call / mcp_approval_request + [JsonPropertyName("call_id")] public string? CallId { get; set; } + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("arguments")] public string? Arguments { get; set; } + + // function_call_output (string or input-part[]) / mcp_call (string) + [JsonPropertyName("output")] public JsonNode? Output { get; set; } + + // reasoning + [JsonPropertyName("summary")] public JsonNode? Summary { get; set; } + [JsonPropertyName("encrypted_content")] public string? EncryptedContent { get; set; } + + // mcp_call / mcp_approval_request + [JsonPropertyName("server_label")] public string? ServerLabel { get; set; } + + // code_interpreter_call + [JsonPropertyName("code")] public string? Code { get; set; } + [JsonPropertyName("container_id")] public string? ContainerId { get; set; } + [JsonPropertyName("outputs")] public JsonNode? Outputs { get; set; } + + // image_generation_call + [JsonPropertyName("result")] public string? Result { get; set; } + + // mcp_approval_response + [JsonPropertyName("approval_request_id")] public string? ApprovalRequestId { get; set; } + [JsonPropertyName("approve")] public bool? Approve { get; set; } + + /// When set, the item is a raw provider Responses output item and is written verbatim. + [JsonIgnore] public JsonObject? RawItem { get; set; } +} + +internal sealed class ResponsesOutputText +{ + [JsonPropertyName("type")] public string Type { get; set; } = "output_text"; + [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; + [JsonPropertyName("annotations")] public List Annotations { get; set; } = []; +} + +internal sealed class ResponsesUsageModel +{ + [JsonPropertyName("input_tokens")] public int InputTokens { get; set; } + [JsonPropertyName("output_tokens")] public int OutputTokens { get; set; } + [JsonPropertyName("total_tokens")] public int TotalTokens { get; set; } +} + +/// SSE payload for response.created / response.completed / response.failed. +internal sealed class ResponsesStreamResponseEvent +{ + [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; + [JsonPropertyName("response")] public ResponsesResponseModel Response { get; set; } = new(); +} + +/// SSE payload for response.output_item.added / response.output_item.done. +internal sealed class ResponsesStreamOutputItemEvent +{ + [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("item")] public ResponsesOutputItem Item { get; set; } = new(); +} + +/// SSE payload for response.content_part.added / response.content_part.done. +internal sealed class ResponsesStreamContentPartEvent +{ + [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; + [JsonPropertyName("item_id")] public string ItemId { get; set; } = string.Empty; + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("part")] public ResponsesOutputText Part { get; set; } = new(); +} + +/// SSE payload for response.output_text.delta. +internal sealed class ResponsesStreamTextDeltaEvent +{ + [JsonPropertyName("type")] public string Type { get; set; } = "response.output_text.delta"; + [JsonPropertyName("item_id")] public string ItemId { get; set; } = string.Empty; + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("delta")] public string Delta { get; set; } = string.Empty; +} + +/// SSE payload for response.output_text.done. +internal sealed class ResponsesStreamTextDoneEvent +{ + [JsonPropertyName("type")] public string Type { get; set; } = "response.output_text.done"; + [JsonPropertyName("item_id")] public string ItemId { get; set; } = string.Empty; + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; +} + +/// SSE payload for response.reasoning_text.delta. +internal sealed class ResponsesStreamReasoningTextDeltaEvent +{ + [JsonPropertyName("type")] public string Type { get; set; } = "response.reasoning_text.delta"; + [JsonPropertyName("item_id")] public string ItemId { get; set; } = string.Empty; + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("delta")] public string Delta { get; set; } = string.Empty; +} + +/// SSE payload for response.reasoning_text.done. +internal sealed class ResponsesStreamReasoningTextDoneEvent +{ + [JsonPropertyName("type")] public string Type { get; set; } = "response.reasoning_text.done"; + [JsonPropertyName("item_id")] public string ItemId { get; set; } = string.Empty; + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; +} + +/// SSE payload for response.function_call_arguments.delta. +internal sealed class ResponsesStreamFunctionCallArgumentsDeltaEvent +{ + [JsonPropertyName("type")] public string Type { get; set; } = "response.function_call_arguments.delta"; + [JsonPropertyName("item_id")] public string ItemId { get; set; } = string.Empty; + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("delta")] public string Delta { get; set; } = string.Empty; +} + +/// SSE payload for response.function_call_arguments.done. +internal sealed class ResponsesStreamFunctionCallArgumentsDoneEvent +{ + [JsonPropertyName("type")] public string Type { get; set; } = "response.function_call_arguments.done"; + [JsonPropertyName("item_id")] public string ItemId { get; set; } = string.Empty; + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("arguments")] public string Arguments { get; set; } = string.Empty; + [JsonPropertyName("name")] public string? Name { get; set; } +} + +/// Error envelope. +internal sealed class ResponsesErrorModel +{ + [JsonPropertyName("error")] public ResponsesErrorBody Error { get; set; } = new(); +} + +internal sealed class ResponsesErrorBody +{ + [JsonPropertyName("type")] public string Type { get; set; } = "invalid_request_error"; + [JsonPropertyName("message")] public string? Message { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesOutputItemConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesOutputItemConverter.cs new file mode 100644 index 00000000000..ee2a73794a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesOutputItemConverter.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// +/// Serializes a . When is set the +/// raw provider node is written verbatim (raw-item passthrough); otherwise the type-discriminated fields are +/// written, omitting nulls so each type carries only its applicable keys. +/// +internal sealed class ResponsesOutputItemConverter : JsonConverter +{ + public override ResponsesOutputItem Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var node = JsonNode.Parse(ref reader); + return new ResponsesOutputItem + { + RawItem = node as JsonObject, + Type = node?["type"]?.GetValue() ?? "message", + Id = node?["id"]?.GetValue() ?? string.Empty, + }; + } + + public override void Write(Utf8JsonWriter writer, ResponsesOutputItem value, JsonSerializerOptions options) + { + if (value.RawItem is not null) + { + value.RawItem.WriteTo(writer, options); + return; + } + + writer.WriteStartObject(); + writer.WriteString("type", value.Type); + writer.WriteString("id", value.Id); + WriteOptionalString(writer, "status", value.Status); + WriteOptionalString(writer, "role", value.Role); + WriteOptionalNode(writer, "content", value.Content, options); + WriteOptionalString(writer, "call_id", value.CallId); + WriteOptionalString(writer, "name", value.Name); + WriteOptionalString(writer, "arguments", value.Arguments); + WriteOptionalNode(writer, "output", value.Output, options); + WriteOptionalNode(writer, "summary", value.Summary, options); + WriteOptionalString(writer, "encrypted_content", value.EncryptedContent); + WriteOptionalString(writer, "server_label", value.ServerLabel); + WriteOptionalString(writer, "code", value.Code); + WriteOptionalString(writer, "container_id", value.ContainerId); + WriteOptionalNode(writer, "outputs", value.Outputs, options); + WriteOptionalString(writer, "result", value.Result); + WriteOptionalString(writer, "approval_request_id", value.ApprovalRequestId); + if (value.Approve is { } approve) + { + writer.WriteBoolean("approve", approve); + } + + writer.WriteEndObject(); + } + + private static void WriteOptionalString(Utf8JsonWriter writer, string name, string? value) + { + if (value is not null) + { + writer.WriteString(name, value); + } + } + + private static void WriteOptionalNode(Utf8JsonWriter writer, string name, JsonNode? value, JsonSerializerOptions options) + { + if (value is not null) + { + writer.WritePropertyName(name); + value.WriteTo(writer, options); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesOutputRenderer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesOutputRenderer.cs new file mode 100644 index 00000000000..a943e3476ba --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesOutputRenderer.cs @@ -0,0 +1,581 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// +/// Renders a flat sequence of into OpenAI Responses output items, mirroring the Python +/// _contents_to_output_items flow: per content-type projection, call/result coalescing (image generation, +/// MCP, code interpreter), raw-item passthrough/replacement, and media projection into input-content parts. +/// +internal static class ResponsesOutputRenderer +{ + public static List Render(IReadOnlyList contents, string status) + { + var items = new List(); + var seenRaw = new Dictionary<(string Type, string Id), int>(); + List? messageContent = null; + + void FlushMessage() + { + if (messageContent is { Count: > 0 }) + { + items.Add(BuildMessage(messageContent, status, id: null)); + messageContent = null; + } + } + + for (var index = 0; index < contents.Count; index++) + { + var content = contents[index]; + + var raw = TryRawItem(content); + if (raw is not null) + { + var key = RawItemKey(raw); + if (key is { } k && seenRaw.TryGetValue(k, out var existing)) + { + items[existing] = new ResponsesOutputItem { Type = raw["type"]!.GetValue(), Id = raw["id"]?.GetValue() ?? string.Empty, RawItem = raw }; + } + else + { + FlushMessage(); + if (key is { } nk) + { + seenRaw[nk] = items.Count; + } + + items.Add(new ResponsesOutputItem { Type = raw["type"]!.GetValue(), Id = raw["id"]?.GetValue() ?? string.Empty, RawItem = raw }); + } + + continue; + } + + var next = index + 1 < contents.Count ? contents[index + 1] : null; + + if (content is CodeInterpreterToolCallContent ciCall && next is CodeInterpreterToolResultContent ciResult && ciCall.CallId == ciResult.CallId) + { + FlushMessage(); + items.Add(CodeInterpreterItem(ciCall, ciResult, status)); + index++; + continue; + } + + if (content is ImageGenerationToolCallContent igCall && next is ImageGenerationToolResultContent igResult && igCall.CallId == igResult.CallId) + { + FlushMessage(); + items.Add(ImageGenerationItem(igCall, igResult, status)); + index++; + continue; + } + + if (content is McpServerToolCallContent mcpCall && next is McpServerToolResultContent mcpResult && mcpCall.CallId == mcpResult.CallId) + { + FlushMessage(); + items.Add(McpCallItem(mcpCall, mcpResult, status)); + index++; + continue; + } + + switch (content) + { + case TextContent text: + (messageContent ??= []).Add(OutputTextNode(text.Text ?? string.Empty)); + break; + case ErrorContent error: + (messageContent ??= []).Add(OutputTextNode(error.Message ?? error.ToString() ?? string.Empty)); + break; + case TextReasoningContent reasoning: + FlushMessage(); + items.Add(ReasoningItem(reasoning, status)); + break; + case FunctionCallContent call: + FlushMessage(); + items.Add(FunctionCallItem(call, status)); + break; + case FunctionResultContent result: + FlushMessage(); + items.Add(FunctionResultItem(result, status)); + break; + case CodeInterpreterToolCallContent ci: + FlushMessage(); + items.Add(CodeInterpreterItem(ci, null, status)); + break; + case CodeInterpreterToolResultContent ciOnly: + FlushMessage(); + items.Add(CodeInterpreterItem(null, ciOnly, status)); + break; + case ImageGenerationToolCallContent ig: + FlushMessage(); + items.Add(ImageGenerationItem(ig, null, status)); + break; + case ImageGenerationToolResultContent igOnly: + FlushMessage(); + items.Add(ImageGenerationItem(null, igOnly, status)); + break; + case McpServerToolCallContent mcp: + FlushMessage(); + items.Add(McpCallItem(mcp, null, status)); + break; + case McpServerToolResultContent mcpResultOnly: + FlushMessage(); + items.Add(McpResultItem(mcpResultOnly, status)); + break; + case ToolApprovalRequestContent approvalRequest: + FlushMessage(); + items.Add(ApprovalRequestItem(approvalRequest)); + break; + case ToolApprovalResponseContent approvalResponse: + FlushMessage(); + items.Add(ApprovalResponseItem(approvalResponse)); + break; + case DataContent or UriContent or HostedFileContent: + FlushMessage(); + items.Add(MediaItem(content, status)); + break; + default: + var fallback = content.ToString(); + if (!string.IsNullOrEmpty(fallback)) + { + (messageContent ??= []).Add(OutputTextNode(fallback)); + } + + break; + } + } + + FlushMessage(); + return items; + } + + private static string MessageStatus(string status) => + status is "in_progress" or "completed" or "incomplete" ? status : "incomplete"; + + private static JsonObject OutputTextNode(string text) => + new() { ["type"] = "output_text", ["text"] = text, ["annotations"] = new JsonArray() }; + + private static JsonArray Append(JsonArray array, JsonNode node) + { + array.Add(node); + return array; + } + + private static ResponsesOutputItem BuildMessage(List content, string status, string? id) + { + var array = new JsonArray(); + foreach (var node in content) + { + Append(array, node); + } + + return new ResponsesOutputItem + { + Type = "message", + Id = id ?? "msg_" + Guid.NewGuid().ToString("N"), + Role = "assistant", + Status = MessageStatus(status), + Content = array, + }; + } + + private static ResponsesOutputItem ReasoningItem(TextReasoningContent reasoning, string status) + { + var text = reasoning.Text ?? string.Empty; + var item = new ResponsesOutputItem + { + Type = "reasoning", + Id = "rs_" + Guid.NewGuid().ToString("N"), + Summary = new JsonArray(), + Status = MessageStatus(status), + }; + if (text.Length > 0) + { + item.Content = new JsonArray(new JsonObject { ["type"] = "reasoning_text", ["text"] = text }); + } + + if (!string.IsNullOrEmpty(reasoning.ProtectedData)) + { + item.EncryptedContent = reasoning.ProtectedData; + } + + return item; + } + + private static ResponsesOutputItem FunctionCallItem(FunctionCallContent call, string status) => new() + { + Type = "function_call", + Id = "fc_" + Guid.NewGuid().ToString("N"), + CallId = call.CallId ?? "call_" + Guid.NewGuid().ToString("N"), + Name = string.IsNullOrEmpty(call.Name) ? "tool" : call.Name, + Arguments = SerializeArguments(call.Arguments), + Status = MessageStatus(status), + }; + + private static ResponsesOutputItem FunctionResultItem(FunctionResultContent result, string status) + { + JsonNode output; + if (result.Exception is { } ex) + { + output = JsonValue.Create(ex.Message)!; + } + else if (ContentPartsToInputItems(AsContentList(result.Result)) is { Count: > 0 } parts) + { + output = parts; + } + else if (result.Result is string s) + { + output = JsonValue.Create(s)!; + } + else if (result.Result is null) + { + output = JsonValue.Create(string.Empty)!; + } + else + { + output = JsonValue.Create(result.Result.ToString() ?? string.Empty)!; + } + + return new ResponsesOutputItem + { + Type = "function_call_output", + Id = "fcout_" + Guid.NewGuid().ToString("N"), + CallId = result.CallId ?? "call_" + Guid.NewGuid().ToString("N"), + Output = output, + Status = MessageStatus(status), + }; + } + + private static ResponsesOutputItem MediaItem(AIContent content, string status) + { + var parts = ContentPartsToInputItems([content]); + if (parts is { Count: > 0 }) + { + return new ResponsesOutputItem + { + Type = "function_call_output", + Id = "content_" + Guid.NewGuid().ToString("N"), + CallId = "content_" + Guid.NewGuid().ToString("N"), + Output = parts, + Status = MessageStatus(status), + }; + } + + return BuildMessage([OutputTextNode(content.ToString() ?? string.Empty)], status, id: null); + } + + private static ResponsesOutputItem McpCallItem(McpServerToolCallContent call, McpServerToolResultContent? result, string status) => new() + { + Type = "mcp_call", + Id = call.CallId ?? "mcp_" + Guid.NewGuid().ToString("N"), + ServerLabel = string.IsNullOrEmpty(call.ServerName) ? "default" : call.ServerName, + Name = string.IsNullOrEmpty(call.Name) ? "tool" : call.Name, + Arguments = SerializeArguments(call.Arguments), + Output = result is not null ? JsonValue.Create(StringifyOutputs(result.Outputs)) : null, + Status = MessageStatus(status), + }; + + private static ResponsesOutputItem McpResultItem(McpServerToolResultContent result, string status) => new() + { + Type = "mcp_call", + Id = result.CallId ?? "mcp_" + Guid.NewGuid().ToString("N"), + ServerLabel = "default", + Name = "tool", + Arguments = string.Empty, + Output = JsonValue.Create(StringifyOutputs(result.Outputs)), + Status = MessageStatus(status), + }; + + private static ResponsesOutputItem CodeInterpreterItem(CodeInterpreterToolCallContent? call, CodeInterpreterToolResultContent? result, string status) + { + var code = call is not null ? ContentSequenceText(call.Inputs) : string.Empty; + var outputsValue = result?.Outputs; + JsonArray? outputs = null; + if (outputsValue is not null) + { + foreach (var item in outputsValue) + { + switch (item) + { + case TextContent t: + Append(outputs ??= [], new JsonObject { ["type"] = "logs", ["logs"] = t.Text ?? string.Empty }); + break; + case UriContent u: + Append(outputs ??= [], new JsonObject { ["type"] = "image", ["url"] = u.Uri.ToString() }); + break; + case DataContent d when !string.IsNullOrEmpty(d.Uri): + Append(outputs ??= [], new JsonObject { ["type"] = "image", ["url"] = d.Uri }); + break; + } + } + } + + return new ResponsesOutputItem + { + Type = "code_interpreter_call", + Id = (call?.CallId ?? result?.CallId) ?? "ci_" + Guid.NewGuid().ToString("N"), + Code = code, + ContainerId = "agent_framework", + Outputs = outputs, + Status = MessageStatus(status), + }; + } + + private static ResponsesOutputItem ImageGenerationItem(ImageGenerationToolCallContent? call, ImageGenerationToolResultContent? result, string status) => new() + { + Type = "image_generation_call", + Id = (call?.CallId ?? result?.CallId) ?? "ig_" + Guid.NewGuid().ToString("N"), + Result = ImageGenerationResult(result?.Outputs), + Status = MessageStatus(status), + }; + + private static ResponsesOutputItem ApprovalRequestItem(ToolApprovalRequestContent content) + { + var (name, arguments, serverLabel) = DescribeToolCall(content.ToolCall); + return new ResponsesOutputItem + { + Type = "mcp_approval_request", + Id = string.IsNullOrEmpty(content.RequestId) ? "approval_" + Guid.NewGuid().ToString("N") : content.RequestId, + ServerLabel = serverLabel, + Name = name, + Arguments = arguments, + }; + } + + private static ResponsesOutputItem ApprovalResponseItem(ToolApprovalResponseContent content) => new() + { + Type = "mcp_approval_response", + Id = string.IsNullOrEmpty(content.RequestId) ? "approval_" + Guid.NewGuid().ToString("N") : content.RequestId, + ApprovalRequestId = content.RequestId ?? string.Empty, + Approve = content.Approved, + }; + + private static (string Name, string Arguments, string ServerLabel) DescribeToolCall(ToolCallContent? toolCall) + { + switch (toolCall) + { + case McpServerToolCallContent mcp: + return (string.IsNullOrEmpty(mcp.Name) ? "tool" : mcp.Name, SerializeArguments(mcp.Arguments), string.IsNullOrEmpty(mcp.ServerName) ? "agent_framework" : mcp.ServerName); + case FunctionCallContent fn: + var label = "agent_framework"; + if (fn.AdditionalProperties is { } props && props.TryGetValue("server_label", out var raw) && raw is string s && !string.IsNullOrEmpty(s)) + { + label = s; + } + + return (string.IsNullOrEmpty(fn.Name) ? "tool" : fn.Name, SerializeArguments(fn.Arguments), label); + default: + return ("tool", string.Empty, "agent_framework"); + } + } + + private static IReadOnlyList AsContentList(object? value) => value switch + { + IReadOnlyList list => list, + IEnumerable seq => [.. seq], + _ => [], + }; + + private static JsonArray? ContentPartsToInputItems(IReadOnlyList contents) + { + if (contents.Count == 0) + { + return null; + } + + JsonArray? parts = null; + foreach (var content in contents) + { + switch (content) + { + case TextContent text: + Append(parts ??= [], new JsonObject { ["type"] = "input_text", ["text"] = text.Text ?? string.Empty }); + break; + case UriContent uri: + AddMediaPart(ref parts, uri.Uri.ToString(), IsImage(uri.MediaType)); + break; + case DataContent data when !string.IsNullOrEmpty(data.Uri): + AddMediaPart(ref parts, data.Uri, IsImage(data.MediaType)); + break; + case HostedFileContent file when !string.IsNullOrEmpty(file.FileId): + Append(parts ??= [], new JsonObject { ["type"] = "input_file", ["file_id"] = file.FileId }); + break; + } + } + + return parts; + } + + private static void AddMediaPart(ref JsonArray? parts, string uri, bool isImage) + { + if (isImage) + { + Append(parts ??= [], new JsonObject { ["type"] = "input_image", ["image_url"] = uri, ["detail"] = "auto" }); + } + else + { + Append(parts ??= [], new JsonObject { ["type"] = "input_file", ["file_url"] = uri }); + } + } + + private static bool IsImage(string? mediaType) => + mediaType?.StartsWith("image/", StringComparison.OrdinalIgnoreCase) == true; + + private static string StringifyOutputs(IList? outputs) + { + if (outputs is null || outputs.Count == 0) + { + return string.Empty; + } + + var sb = new StringBuilder(); + foreach (var content in outputs) + { + if (content is TextContent text) + { + sb.Append(text.Text); + } + } + + return sb.ToString(); + } + + private static string ContentSequenceText(IList? contents) + { + if (contents is null) + { + return string.Empty; + } + + var sb = new StringBuilder(); + foreach (var content in contents) + { + if (content is TextContent text) + { + sb.Append(text.Text); + } + } + + return sb.ToString(); + } + + private static string ImageGenerationResult(IList? outputs) + { + if (outputs is null) + { + return string.Empty; + } + + foreach (var content in outputs) + { + var uri = content switch + { + DataContent d => d.Uri, + UriContent u => u.Uri.ToString(), + _ => null, + }; + if (uri is null) + { + continue; + } + + var marker = uri.IndexOf("base64,", StringComparison.Ordinal); + return marker >= 0 ? uri[(marker + "base64,".Length)..] : uri; + } + + return string.Empty; + } + + private static JsonObject? TryRawItem(AIContent content) + { + if (content.RawRepresentation is null) + { + return null; + } + + JsonNode? node = content.RawRepresentation switch + { + JsonObject obj => obj.DeepClone(), + JsonNode jn => jn.DeepClone(), + JsonElement { ValueKind: JsonValueKind.Object } el => JsonNode.Parse(el.GetRawText()), + _ => null, + }; + + if (node is JsonObject result && result.ContainsKey("type")) + { + return result; + } + + return null; + } + + private static (string Type, string Id)? RawItemKey(JsonObject raw) + { + if (raw["type"]?.GetValue() is { } type && raw["id"]?.GetValue() is { } id) + { + return (type, id); + } + + return null; + } + + internal static string SerializeArguments(IDictionary? arguments) + { + if (arguments is null || arguments.Count == 0) + { + return "{}"; + } + + var buffer = new System.Buffers.ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + foreach (var (key, value) in arguments) + { + writer.WritePropertyName(key); + WriteArgumentValue(writer, value); + } + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + private static void WriteArgumentValue(Utf8JsonWriter writer, object? value) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case string s: + writer.WriteStringValue(s); + break; + case bool b: + writer.WriteBooleanValue(b); + break; + case int i: + writer.WriteNumberValue(i); + break; + case long l: + writer.WriteNumberValue(l); + break; + case double d: + writer.WriteNumberValue(d); + break; + case JsonElement el: + el.WriteTo(writer); + break; + default: + writer.WriteStringValue(Convert.ToString(value, CultureInfo.InvariantCulture)); + break; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesParsing.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesParsing.cs new file mode 100644 index 00000000000..9390e9738aa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesParsing.cs @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// +/// Parses the OpenAI Responses request body into Agent Framework constructs: the input field (string +/// or input-item array) into a list, the generation-control fields into +/// , and the caller identity into a . Mirrors the Python +/// _parsing module. Malformed shapes raise (HTTP 422). +/// +internal static class ResponsesParsing +{ + /// Translate input (string or list of items) into messages. + public static IReadOnlyList MessagesFromInput(JsonElement? input) + { + if (input is null || input.Value.ValueKind == JsonValueKind.Null) + { + throw new FormatException("`input` must be a non-empty string or list"); + } + + var value = input.Value; + + if (value.ValueKind == JsonValueKind.String) + { + return [new ChatMessage(ChatRole.User, value.GetString() ?? string.Empty)]; + } + + if (value.ValueKind != JsonValueKind.Array || value.GetArrayLength() == 0) + { + throw new FormatException("`input` must be a non-empty string or list"); + } + + var messages = new List(); + var pendingUserParts = new List(); + + void Flush() + { + if (pendingUserParts.Count > 0) + { + messages.Add(new ChatMessage(ChatRole.User, new List(pendingUserParts))); + pendingUserParts.Clear(); + } + } + + foreach (var item in value.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + { + throw new FormatException("each `input` item must be an object"); + } + + if (item.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String && typeEl.GetString() == "message") + { + Flush(); + var role = item.TryGetProperty("role", out var roleEl) && roleEl.ValueKind == JsonValueKind.String + ? roleEl.GetString() ?? "user" + : "user"; + + var parts = new List(); + if (item.TryGetProperty("content", out var content)) + { + if (content.ValueKind == JsonValueKind.String) + { + parts.Add(new TextContent(content.GetString() ?? string.Empty)); + } + else if (content.ValueKind == JsonValueKind.Array) + { + foreach (var contentItem in content.EnumerateArray()) + { + if (contentItem.ValueKind != JsonValueKind.Object) + { + throw new FormatException("each message `content` item must be an object"); + } + + parts.Add(ContentFromInputItem(contentItem)); + } + } + else if (content.ValueKind != JsonValueKind.Null) + { + throw new FormatException("message `content` must be a string or list"); + } + } + + messages.Add(new ChatMessage(MapRole(role), parts)); + } + else + { + pendingUserParts.Add(ContentFromInputItem(item)); + } + } + + Flush(); + + if (messages.Count == 0) + { + throw new FormatException("`input` produced no messages"); + } + + return messages; + } + + /// Build from the generation-control fields, or when none are set. + public static ChatOptions? BuildOptions(ResponsesRequestModel body) + { + ChatOptions? options = null; + + ChatOptions Ensure() => options ??= new ChatOptions(); + + if (!string.IsNullOrEmpty(body.Instructions)) + { + Ensure().Instructions = body.Instructions; + } + + if (body.Temperature is { } temperature) + { + Ensure().Temperature = (float)temperature; + } + + if (body.TopP is { } topP) + { + Ensure().TopP = (float)topP; + } + + if (body.MaxOutputTokens is { } maxOutputTokens) + { + Ensure().MaxOutputTokens = maxOutputTokens; + } + + if (body.ParallelToolCalls is { } parallelToolCalls) + { + Ensure().AllowMultipleToolCalls = parallelToolCalls; + } + + return options; + } + + /// Surface the caller as a via safety_identifier (falling back to user). + public static ChannelIdentity? ParseIdentity(ResponsesRequestModel body, string channelName) + { + var native = !string.IsNullOrEmpty(body.SafetyIdentifier) ? body.SafetyIdentifier : body.User; + return string.IsNullOrEmpty(native) ? null : new ChannelIdentity(channelName, native!); + } + + private static AIContent ContentFromInputItem(JsonElement item) + { + var type = item.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String + ? typeEl.GetString() + : null; + + switch (type) + { + case "input_text": + case "output_text": + case "text": + return new TextContent(item.TryGetProperty("text", out var textEl) && textEl.ValueKind == JsonValueKind.String + ? textEl.GetString() ?? string.Empty + : string.Empty); + + case "input_image": + var imageUrl = ReadImageUrl(item); + if (string.IsNullOrEmpty(imageUrl)) + { + throw new FormatException("input_image requires `image_url`"); + } + + return new UriContent(imageUrl!, "image/*"); + + case "input_file": + if (item.TryGetProperty("file_url", out var fileUrlEl) && fileUrlEl.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(fileUrlEl.GetString())) + { + var mime = item.TryGetProperty("mime_type", out var mimeEl) && mimeEl.ValueKind == JsonValueKind.String + ? mimeEl.GetString() + : null; + return new UriContent(fileUrlEl.GetString()!, mime ?? "application/octet-stream"); + } + + if (item.TryGetProperty("file_id", out var fileIdEl) && fileIdEl.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(fileIdEl.GetString())) + { + return new HostedFileContent(fileIdEl.GetString()!); + } + + throw new FormatException("input_file requires `file_url` or `file_id`"); + + default: + throw new FormatException($"Unsupported Responses input content type: '{type}'"); + } + } + + private static string? ReadImageUrl(JsonElement item) + { + if (!item.TryGetProperty("image_url", out var imageUrl)) + { + return null; + } + + if (imageUrl.ValueKind == JsonValueKind.String) + { + return imageUrl.GetString(); + } + + if (imageUrl.ValueKind == JsonValueKind.Object && imageUrl.TryGetProperty("url", out var urlEl) && urlEl.ValueKind == JsonValueKind.String) + { + return urlEl.GetString(); + } + + return null; + } + + private static ChatRole MapRole(string? role) => role switch + { + "assistant" => ChatRole.Assistant, + "system" or "developer" => ChatRole.System, + "tool" => ChatRole.Tool, + _ => ChatRole.User, + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Microsoft.Agents.AI.Hosting.Channels.Responses.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Microsoft.Agents.AI.Hosting.Channels.Responses.csproj new file mode 100644 index 00000000000..849174bd73b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Microsoft.Agents.AI.Hosting.Channels.Responses.csproj @@ -0,0 +1,34 @@ + + + + $(TargetFrameworksCore) + Microsoft.Agents.AI.Hosting.Channels.Responses + alpha + $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated + true + + + + + + true + + + + + + + + + + + + + + + + + Microsoft Agent Framework Hosting Channels - Responses + OpenAI Responses-shaped channel for the Microsoft Agent Framework hosting channels surface. Maps Responses requests and streams onto a shared AgentFrameworkHost. + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs new file mode 100644 index 00000000000..5f7bbb85e84 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs @@ -0,0 +1,459 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// +/// OpenAI Responses-shaped channel. Mounts a single POST {Path} endpoint that accepts a Responses +/// request body and returns either a Responses JSON object (stream=false, default) or a +/// Server-Sent-Events stream (stream=true). The channel owns protocol parsing and rendering; the host +/// owns target invocation and session resolution. +/// +public sealed class ResponsesChannel : Channel +{ + private readonly ResponsesChannelOptions _options; + + /// Initializes a new instance. + public ResponsesChannel(ResponsesChannelOptions options) + { + this._options = Throw.IfNull(options); + } + + /// + public override string Name => "responses"; + + /// + public override string Path => this._options.Path; + + /// + public override ChannelContribution Contribute(ChannelContext context) + { + Throw.IfNull(context); + return new ChannelContribution + { + Routes = [endpoints => endpoints.MapPost("/", (HttpContext http) => this.HandleAsync(context, http))], + }; + } + + private async Task HandleAsync(ChannelContext context, HttpContext http) + { + ResponsesRequestModel? body; + try + { + body = await JsonSerializer.DeserializeAsync(http.Request.Body, ResponsesJsonContext.Default.ResponsesRequestModel, http.RequestAborted).ConfigureAwait(false); + } + catch (JsonException ex) + { + await WriteErrorAsync(http, StatusCodes.Status400BadRequest, ex.Message).ConfigureAwait(false); + return; + } + + if (body is null) + { + await WriteErrorAsync(http, StatusCodes.Status400BadRequest, "Request body is required.").ConfigureAwait(false); + return; + } + + IReadOnlyList messages; + try + { + messages = ResponsesParsing.MessagesFromInput(body.Input); + } + catch (FormatException ex) + { + await WriteErrorAsync(http, StatusCodes.Status422UnprocessableEntity, ex.Message).ConfigureAwait(false); + return; + } + + // Session anchoring (mirrors Python `_handle`): an explicit `previous_response_id` wins; else a + // host-lifted Foundry chat isolation key; else the freshly minted response id anchors the first turn. + var previousResponseId = string.IsNullOrEmpty(body.PreviousResponseId) ? null : body.PreviousResponseId; + ChannelSession? session = previousResponseId is not null ? new ChannelSession { IsolationKey = previousResponseId } : null; + + if (session is null) + { + var chatKey = IsolationKeys.Current?.ChatKey; + if (!string.IsNullOrEmpty(chatKey)) + { + session = new ChannelSession { IsolationKey = chatKey }; + } + } + + var responseId = (this._options.ResponseIdFactory ?? s_defaultResponseIdFactory)(previousResponseId); + session ??= new ChannelSession { IsolationKey = responseId }; + + // The minted response id (and any previous id) travel on attributes so a host-side history provider + // can anchor the storage chain on the same handle the envelope reports. + var attributes = new Dictionary(StringComparer.Ordinal) { ["response_id"] = responseId }; + if (previousResponseId is not null) + { + attributes["previous_response_id"] = previousResponseId; + } + + var request = new ChannelRequest(this.Name, "message.create", messages) + { + Stream = body.Stream, + Session = session, + Identity = ResponsesParsing.ParseIdentity(body, this.Name), + Options = ResponsesParsing.BuildOptions(body), + Attributes = attributes, + }; + + if (this._options.RunHook is not null) + { + var hookContext = new ChannelRunHookContext(context.Host) { ProtocolRequest = body }; + request = await this._options.RunHook.OnRequestAsync(request, hookContext, http.RequestAborted).ConfigureAwait(false); + } + else + { + // Default behavior strips parsed generation options so untrusted callers cannot inject parameters. + request.Options = null; + } + + try + { + if (request.Stream) + { + await this.WriteStreamAsync(context, request, body.Model, responseId, http).ConfigureAwait(false); + } + else + { + await this.WriteJsonResponseAsync(context, request, body.Model, responseId, http).ConfigureAwait(false); + } + } + catch (Exception ex) + { + await WriteErrorAsync(http, StatusCodes.Status500InternalServerError, ex.Message).ConfigureAwait(false); + } + } + + private async Task WriteJsonResponseAsync(ChannelContext context, ChannelRequest request, string? model, string responseId, HttpContext http) + { + var result = await context.RunAsync(request, http.RequestAborted).ConfigureAwait(false); + result = await this.ApplyResponseHookAsync(result, request, http.RequestAborted).ConfigureAwait(false); + + var response = BuildEnvelope(responseId, model, status: "completed"); + response.Output.AddRange(BuildOutputItems(result.ResultObject)); + response.Usage = BuildUsage(result.ResultObject as AgentResponse); + + http.Response.StatusCode = StatusCodes.Status200OK; + http.Response.ContentType = "application/json; charset=utf-8"; + await JsonSerializer.SerializeAsync(http.Response.Body, response, ResponsesJsonContext.Default.ResponsesResponseModel, http.RequestAborted).ConfigureAwait(false); + } + + private async Task WriteStreamAsync(ChannelContext context, ChannelRequest request, string? model, string responseId, HttpContext http) + { + http.Response.StatusCode = StatusCodes.Status200OK; + http.Response.ContentType = "text/event-stream"; + http.Response.Headers.CacheControl = "no-cache"; + + var itemId = "msg_" + Guid.NewGuid().ToString("N"); + var sb = new StringBuilder(); + var accumulated = new List(); + var nextOutputIndex = 1; + HostedRunResult? finalResult = null; + + try + { + var created = BuildEnvelope(responseId, model, status: "in_progress"); + await WriteEventAsync(http, "response.created", new ResponsesStreamResponseEvent { Type = "response.created", Response = created }, ResponsesJsonContext.Default.ResponsesStreamResponseEvent).ConfigureAwait(false); + + var addedItem = new ResponsesOutputItem { Type = "message", Id = itemId, Role = "assistant", Status = "in_progress", Content = new JsonArray() }; + await WriteEventAsync(http, "response.output_item.added", new ResponsesStreamOutputItemEvent { Type = "response.output_item.added", OutputIndex = 0, Item = addedItem }, ResponsesJsonContext.Default.ResponsesStreamOutputItemEvent).ConfigureAwait(false); + await WriteEventAsync(http, "response.content_part.added", new ResponsesStreamContentPartEvent { Type = "response.content_part.added", ItemId = itemId, OutputIndex = 0, ContentIndex = 0, Part = new ResponsesOutputText() }, ResponsesJsonContext.Default.ResponsesStreamContentPartEvent).ConfigureAwait(false); + + var updates = StreamUpdatesAsync(context.StreamAsync(request, http.RequestAborted), r => finalResult = r); + var transformed = this._options.StreamTransformHook is { } hook + ? hook.TransformAsync(updates, http.RequestAborted) + : updates; + + await foreach (var update in transformed.ConfigureAwait(false)) + { + accumulated.Add(update); + foreach (var content in update.Contents) + { + if (content is TextContent text) + { + if (!string.IsNullOrEmpty(text.Text)) + { + sb.Append(text.Text); + var deltaEvent = new ResponsesStreamTextDeltaEvent { ItemId = itemId, OutputIndex = 0, ContentIndex = 0, Delta = text.Text }; + await WriteEventAsync(http, "response.output_text.delta", deltaEvent, ResponsesJsonContext.Default.ResponsesStreamTextDeltaEvent).ConfigureAwait(false); + } + + continue; + } + + if (await this.EmitNonTextContentAsync(http, content, nextOutputIndex).ConfigureAwait(false)) + { + nextOutputIndex++; + } + } + } + } + catch (Exception ex) + { + // Once the SSE stream has started, surface the error as a Responses `response.failed` event + // rather than an (invalid) post-headers JSON error. + var failed = BuildEnvelope(responseId, model, status: "failed"); + failed.Error = new ResponsesErrorBody { Message = ex.Message }; + await WriteEventAsync(http, "response.failed", new ResponsesStreamResponseEvent { Type = "response.failed", Response = failed }, ResponsesJsonContext.Default.ResponsesStreamResponseEvent).ConfigureAwait(false); + return; + } + + // Apply the response hook to the final streamed result (mirrors Python's stream get_final_response). + if (finalResult is not null) + { + finalResult = await this.ApplyResponseHookAsync(finalResult, request, http.RequestAborted).ConfigureAwait(false); + } + + var finalText = sb.ToString(); + await WriteEventAsync(http, "response.output_text.done", new ResponsesStreamTextDoneEvent { ItemId = itemId, OutputIndex = 0, ContentIndex = 0, Text = finalText }, ResponsesJsonContext.Default.ResponsesStreamTextDoneEvent).ConfigureAwait(false); + + var donePart = new ResponsesOutputText { Text = finalText }; + await WriteEventAsync(http, "response.content_part.done", new ResponsesStreamContentPartEvent { Type = "response.content_part.done", ItemId = itemId, OutputIndex = 0, ContentIndex = 0, Part = donePart }, ResponsesJsonContext.Default.ResponsesStreamContentPartEvent).ConfigureAwait(false); + + var doneItem = new ResponsesOutputItem { Type = "message", Id = itemId, Role = "assistant", Status = "completed", Content = new JsonArray(new JsonObject { ["type"] = "output_text", ["text"] = finalText, ["annotations"] = new JsonArray() }) }; + await WriteEventAsync(http, "response.output_item.done", new ResponsesStreamOutputItemEvent { Type = "response.output_item.done", OutputIndex = 0, Item = doneItem }, ResponsesJsonContext.Default.ResponsesStreamOutputItemEvent).ConfigureAwait(false); + + var completed = BuildEnvelope(responseId, model, status: "completed"); + if (finalResult?.ResultObject is AgentResponse hookResponse) + { + completed.Output.AddRange(BuildOutputItems(hookResponse)); + } + else if (accumulated.Count > 0) + { + // Finalize unavailable (e.g. the agent stream could not produce a final response): render the + // completed envelope from the accumulated streamed updates, matching Python's stream finalizer. + completed.Output.AddRange(BuildOutputItems(accumulated.ToAgentResponse())); + } + else + { + completed.Output.Add(doneItem); + } + + await WriteEventAsync(http, "response.completed", new ResponsesStreamResponseEvent { Type = "response.completed", Response = completed }, ResponsesJsonContext.Default.ResponsesStreamResponseEvent).ConfigureAwait(false); + } + + /// + /// Emits the SSE events for a single non-text streamed content. Reasoning and function-call content emit + /// an in-progress item, their incremental delta/done events, then the completed item; every other + /// renderable content emits an output_item.added/done pair carrying the rendered item. Returns whether an + /// output item was produced (and thus the output index should advance). + /// + private async ValueTask EmitNonTextContentAsync(HttpContext http, AIContent content, int outputIndex) + { + switch (content) + { + case TextReasoningContent reasoning: + var reasoningAdded = new ResponsesOutputItem { Type = "reasoning", Id = "rs_" + Guid.NewGuid().ToString("N"), Summary = new JsonArray(), Content = new JsonArray(), Status = "in_progress" }; + await this.WriteOutputItemEventAsync(http, "response.output_item.added", outputIndex, reasoningAdded).ConfigureAwait(false); + if (!string.IsNullOrEmpty(reasoning.Text)) + { + await WriteEventAsync(http, "response.reasoning_text.delta", new ResponsesStreamReasoningTextDeltaEvent { ItemId = reasoningAdded.Id, OutputIndex = outputIndex, ContentIndex = 0, Delta = reasoning.Text! }, ResponsesJsonContext.Default.ResponsesStreamReasoningTextDeltaEvent).ConfigureAwait(false); + await WriteEventAsync(http, "response.reasoning_text.done", new ResponsesStreamReasoningTextDoneEvent { ItemId = reasoningAdded.Id, OutputIndex = outputIndex, ContentIndex = 0, Text = reasoning.Text! }, ResponsesJsonContext.Default.ResponsesStreamReasoningTextDoneEvent).ConfigureAwait(false); + } + + var reasoningDone = ResponsesOutputRenderer.Render([content], status: "completed")[0]; + reasoningDone.Id = reasoningAdded.Id; + await this.WriteOutputItemEventAsync(http, "response.output_item.done", outputIndex, reasoningDone).ConfigureAwait(false); + return true; + + case FunctionCallContent call: + var arguments = ResponsesOutputRenderer.SerializeArguments(call.Arguments); + var callId = call.CallId ?? "call_" + Guid.NewGuid().ToString("N"); + var name = string.IsNullOrEmpty(call.Name) ? "tool" : call.Name; + var callAdded = new ResponsesOutputItem { Type = "function_call", Id = "fc_" + Guid.NewGuid().ToString("N"), CallId = callId, Name = name, Arguments = string.Empty, Status = "in_progress" }; + await this.WriteOutputItemEventAsync(http, "response.output_item.added", outputIndex, callAdded).ConfigureAwait(false); + if (call.Arguments is { Count: > 0 }) + { + await WriteEventAsync(http, "response.function_call_arguments.delta", new ResponsesStreamFunctionCallArgumentsDeltaEvent { ItemId = callAdded.Id, OutputIndex = outputIndex, Delta = arguments }, ResponsesJsonContext.Default.ResponsesStreamFunctionCallArgumentsDeltaEvent).ConfigureAwait(false); + await WriteEventAsync(http, "response.function_call_arguments.done", new ResponsesStreamFunctionCallArgumentsDoneEvent { ItemId = callAdded.Id, OutputIndex = outputIndex, Arguments = arguments, Name = name }, ResponsesJsonContext.Default.ResponsesStreamFunctionCallArgumentsDoneEvent).ConfigureAwait(false); + } + + var callDone = ResponsesOutputRenderer.Render([content], status: "completed")[0]; + callDone.Id = callAdded.Id; + await this.WriteOutputItemEventAsync(http, "response.output_item.done", outputIndex, callDone).ConfigureAwait(false); + return true; + + default: + var items = ResponsesOutputRenderer.Render([content], status: "completed"); + var produced = false; + foreach (var item in items) + { + produced = true; + await this.WriteOutputItemEventAsync(http, "response.output_item.added", outputIndex, item).ConfigureAwait(false); + await this.WriteOutputItemEventAsync(http, "response.output_item.done", outputIndex, item).ConfigureAwait(false); + } + + return produced; + } + } + + private Task WriteOutputItemEventAsync(HttpContext http, string type, int outputIndex, ResponsesOutputItem item) => + WriteEventAsync(http, type, new ResponsesStreamOutputItemEvent { Type = type, OutputIndex = outputIndex, Item = item }, ResponsesJsonContext.Default.ResponsesStreamOutputItemEvent); + + private static async IAsyncEnumerable StreamUpdatesAsync( + IAsyncEnumerable items, + Action captureFinal) + { + await foreach (var item in items.ConfigureAwait(false)) + { + switch (item) + { + case HostedStreamUpdate update: + yield return update.Update; + break; + case HostedStreamCompleted completed: + captureFinal(completed.Result); + break; + } + } + } + + private async ValueTask ApplyResponseHookAsync(HostedRunResult result, ChannelRequest request, CancellationToken cancellationToken) + { + if (this._options.ResponseHook is null) + { + return result; + } + var ctx = new ChannelResponseContext(request, this.Name); + return await this._options.ResponseHook.OnResponseAsync(result, ctx, cancellationToken).ConfigureAwait(false); + } + + private static ResponsesResponseModel BuildEnvelope(string id, string? model, string status) => new() + { + Id = id, + CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Status = status, + Model = string.IsNullOrEmpty(model) ? "agent" : model, + }; + + private static ResponsesUsageModel? BuildUsage(AgentResponse? agentResponse) + { + if (agentResponse?.Usage is not { } usage) + { + return null; + } + + return new ResponsesUsageModel + { + InputTokens = (int)(usage.InputTokenCount ?? 0), + OutputTokens = (int)(usage.OutputTokenCount ?? 0), + TotalTokens = (int)(usage.TotalTokenCount ?? 0), + }; + } + + /// + /// Render an agent result's messages as Responses output items, mirroring the Python channel: consecutive + /// text coalesces into one assistant message item; reasoning, function calls, and function results each + /// become their own typed output item. Unmodeled content falls back to text. + /// + private static List BuildOutputItems(object? resultObject) + { + var contents = new List(); + foreach (var message in ExtractMessages(resultObject)) + { + contents.AddRange(message.Contents); + } + + var items = ResponsesOutputRenderer.Render(contents, status: "completed"); + if (items.Count == 0) + { + items.Add(new ResponsesOutputItem + { + Type = "message", + Id = "msg_" + Guid.NewGuid().ToString("N"), + Role = "assistant", + Status = "completed", + Content = new JsonArray(new JsonObject { ["type"] = "output_text", ["text"] = ExtractText(resultObject), ["annotations"] = new JsonArray() }), + }); + } + + return items; + } + + private static IEnumerable ExtractMessages(object? resultObject) => resultObject switch + { + AgentResponse response => response.Messages, + WorkflowRunResult workflow => FlattenWorkflowOutputs(workflow.Outputs), + _ => [], + }; + + private static IEnumerable FlattenWorkflowOutputs(IReadOnlyList outputs) + { + foreach (var output in outputs) + { + switch (output) + { + case null: + break; + case ChatMessage message: + yield return message; + break; + case AgentResponse response: + foreach (var message in response.Messages) + { + yield return message; + } + + break; + case IEnumerable messages: + foreach (var message in messages) + { + yield return message; + } + + break; + case AIContent content: + yield return new ChatMessage(ChatRole.Assistant, [content]); + break; + case string text: + yield return new ChatMessage(ChatRole.Assistant, text); + break; + default: + yield return new ChatMessage(ChatRole.Assistant, output.ToString() ?? string.Empty); + break; + } + } + } + + private static string ExtractText(object? resultObject) => resultObject switch + { + AgentResponse response => response.Text, + AgentResponseUpdate update => update.Text, + string s => s, + _ => resultObject?.ToString() ?? string.Empty, + }; + + private static readonly Func s_defaultResponseIdFactory = _ => "resp_" + Guid.NewGuid().ToString("N"); + + private static async Task WriteEventAsync(HttpContext http, string eventType, T payload, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo) + { + var json = JsonSerializer.Serialize(payload, typeInfo); + var frame = string.Create(CultureInfo.InvariantCulture, $"event: {eventType}\ndata: {json}\n\n"); + await http.Response.WriteAsync(frame, http.RequestAborted).ConfigureAwait(false); + await http.Response.Body.FlushAsync(http.RequestAborted).ConfigureAwait(false); + } + + private static async Task WriteErrorAsync(HttpContext http, int statusCode, string? message) + { + http.Response.StatusCode = statusCode; + http.Response.ContentType = "application/json; charset=utf-8"; + var error = new ResponsesErrorModel { Error = new ResponsesErrorBody { Message = message } }; + await JsonSerializer.SerializeAsync(http.Response.Body, error, ResponsesJsonContext.Default.ResponsesErrorModel, http.RequestAborted).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannelOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannelOptions.cs new file mode 100644 index 00000000000..1acb7e08dc0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannelOptions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// +/// Configuration for . +/// +public sealed class ResponsesChannelOptions +{ + /// Mount root for the Responses route. Default "/responses"; use "" for the app root. + public string Path { get; set; } = "/responses"; + + /// + /// Optional run hook invoked after the channel parses the request and before the host invokes the target. + /// Supplying a hook replaces the default behavior, which strips all parsed generation options so + /// untrusted callers cannot inject parameters; a custom hook receives the request with options populated + /// and decides what to forward. + /// + public IChannelRunHook? RunHook { get; set; } + + /// Optional response hook invoked before the channel serializes the originating response. + public IChannelResponseHook? ResponseHook { get; set; } + + /// + /// Optional stream-update hook applied by the host while the channel consumes streamed updates, before + /// the channel renders Server-Sent-Events. Applies only to streaming requests. + /// + public IChannelStreamTransformHook? StreamTransformHook { get; set; } + + /// + /// Optional factory that mints the per-request response id, receiving the caller's + /// previous_response_id (or ) as a co-location hint. Default produces + /// resp_<uuid>. Override when the host backing storage requires a different id format. + /// + public Func? ResponseIdFactory { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHost.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHost.cs new file mode 100644 index 00000000000..3ad5b9fc80b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHost.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// The channel-neutral host composed by AddAgentFrameworkHost(...) and surfaced via DI. Owns target +/// invocation, the channels collection, the host state store, and . v1 has no +/// authorization pipeline, response routing, or background delivery (those are ADR-0028). +/// +public sealed class AgentFrameworkHost +{ + internal AgentFrameworkHost( + IServiceProvider services, + IHostedTargetRunner targetRunner, + IReadOnlyList channels, + IHostStateStore stateStore, + AgentFrameworkHostOptions options) + { + this.Services = Throw.IfNull(services); + this.TargetRunner = Throw.IfNull(targetRunner); + this.Channels = Throw.IfNull(channels); + this.StateStore = Throw.IfNull(stateStore); + this.Options = Throw.IfNull(options); + } + + /// Application service provider. + public IServiceProvider Services { get; } + + /// Registered channels in registration order. + public IReadOnlyList Channels { get; } + + /// The configured target runner. + public IHostedTargetRunner TargetRunner { get; } + + /// The shared host state store. + public IHostStateStore StateStore { get; } + + /// Composition-time options. + public AgentFrameworkHostOptions Options { get; } + + /// Run the target with the given request. + public ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default) + { + Throw.IfNull(request); + return this.TargetRunner.RunAsync(request, cancellationToken); + } + + /// Stream the target's response. + public IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default) + { + Throw.IfNull(request); + return this.TargetRunner.StreamAsync(request, cancellationToken); + } + + /// Rotate the active session alias for an isolation key (host-tracked channels' /new). + public ValueTask ResetSessionAsync(string isolationKey, CancellationToken cancellationToken = default) + => this.StateStore.RotateSessionAliasAsync(isolationKey, cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs new file mode 100644 index 00000000000..eaeb4eedd9a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Composition-time options for AddAgentFrameworkHost(...). +/// +public sealed class AgentFrameworkHostOptions +{ + /// File-system layout for the file-backed host state store. + public HostStatePathOptions? StatePaths { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs new file mode 100644 index 00000000000..def9a302667 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Base type for hosting channels. Authors derive from this to expose an inbound surface (HTTP routes, +/// long-poll loops, ...) and optionally mix in / / +/// . +/// +/// +/// Two-phase lifecycle: runs at AddXxxChannel time (pre-Build); +/// runs at MapAgentFrameworkHost time (post-Build). +/// +public abstract class Channel +{ + /// Stable channel name. Matches / . + public abstract string Name { get; } + + /// + /// Mount root for the channel's routes. The host wraps in + /// endpoints.MapGroup(Path). Empty mounts at the host root. + /// + public virtual string Path => string.Empty; + + /// Registers DI services the channel needs. Runs pre-Build. + public virtual void ConfigureServices(IServiceCollection services) + { + } + + /// Returns the channel's contribution (routes, commands, lifecycle hooks, endpoint filters). Runs post-Build. + public abstract ChannelContribution Contribute(ChannelContext context); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs new file mode 100644 index 00000000000..e1bc41800db --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// A discoverable command a channel exposes to its users (e.g. a Telegram /reset slash command or a +/// Discord application command). Channels emit these from as metadata for +/// native registration with the protocol, and dispatch a matched command by invoking +/// with a . +/// +public sealed class ChannelCommand +{ + /// Gets the command name without any leading sentinel (e.g. "reset" not "/reset"). + public string Name { get; } + + /// Gets the short description surfaced in the protocol's UI. + public string Description { get; } + + /// Gets the handler invoked when the channel dispatches this command. + public Func Handler { get; } + + /// Initializes a new instance of . + /// The command name without any leading sentinel (e.g. "reset" not "/reset"). + /// Short description surfaced in the protocol's UI. + /// The handler invoked when the channel dispatches this command. + public ChannelCommand(string name, string description, Func handler) + { + this.Name = Throw.IfNullOrEmpty(name); + this.Description = Throw.IfNull(description); + this.Handler = Throw.IfNull(handler); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommandContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommandContext.cs new file mode 100644 index 00000000000..bcb02f2bec8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommandContext.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Context passed to a when a channel dispatches a recognized command. +/// Carries the originating request and a protocol-native callback the handler uses to +/// respond. +/// +public sealed class ChannelCommandContext +{ + /// Gets the originating channel request. + public ChannelRequest Request { get; } + + /// Gets the channel-supplied callback the handler invokes to send a reply. + public Func Reply { get; } + + /// Initializes a new instance of . + /// The originating channel request. + /// The channel-supplied callback the handler invokes to send a reply. + public ChannelCommandContext(ChannelRequest request, Func reply) + { + this.Request = Throw.IfNull(request); + this.Reply = Throw.IfNull(reply); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContext.cs new file mode 100644 index 00000000000..476aed9629f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContext.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Handed to . Exposes the host's run / stream surface plus the host and +/// its state store. The host owns construction; channels consume it (they never create or derive from it), +/// so this is a sealed concrete type rather than an interface. +/// +public sealed class ChannelContext +{ + internal ChannelContext(IServiceProvider services, AgentFrameworkHost host) + { + this.Services = Throw.IfNull(services); + this.Host = Throw.IfNull(host); + } + + /// Gets the application service provider. + public IServiceProvider Services { get; } + + /// Gets the host this channel was added to. + public AgentFrameworkHost Host { get; } + + /// Gets the host state store. + public IHostStateStore StateStore => this.Host.StateStore; + + /// Runs the host target and returns the (non-streaming) result. + public ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default) + => this.Host.RunAsync(request, cancellationToken); + + /// Streams the host target's response as envelopes. + public IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default) + => this.Host.StreamAsync(request, cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs new file mode 100644 index 00000000000..9dc72f82e1a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Returned by . Carries the routes, commands, endpoint filters, +/// and lifecycle hooks the channel publishes to the running host. +/// +public sealed class ChannelContribution +{ + /// + /// Gets or sets the route registration actions. The host invokes each one with an + /// rooted at via ' + /// group semantics, so map paths relative to . + /// + public IReadOnlyList> Routes { get; set; } = []; + + /// + /// Gets or sets the endpoint filters applied to the -rooted group. Replaces Python's + /// middleware slot. + /// + public IReadOnlyList EndpointFilters { get; set; } = []; + + /// Gets or sets the declarative commands; channels read these and call the protocol's native registration. + public IReadOnlyList Commands { get; set; } = []; + + /// Gets or sets the optional startup hook invoked once after DI is built. Useful for long-poll loops. + public Func? OnStartup { get; set; } + + /// Gets or sets the optional shutdown hook invoked during graceful shutdown. + public Func? OnShutdown { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs new file mode 100644 index 00000000000..67deed0321a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Channel-native user identity observed on a . +/// +public sealed class ChannelIdentity +{ + /// Gets the originating channel name (matches ). + public string Channel { get; } + + /// Gets the channel-native USER identifier (never the chat or conversation id). + public string NativeId { get; } + + /// + /// Gets or sets the channel-defined attributes attached to this identity (e.g. display name, language). + /// + public IReadOnlyDictionary Attributes { get; set; } = + ImmutableDictionary.Empty; + + /// Initializes a new instance of . + /// The originating channel name (matches ). + /// The channel-native USER identifier (never the chat or conversation id). + public ChannelIdentity(string channel, string nativeId) + { + this.Channel = channel; + this.NativeId = nativeId; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs new file mode 100644 index 00000000000..991f4fe7f3d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Channel-neutral request envelope handed to / +/// . Channels build this from their wire format. +/// +public sealed class ChannelRequest +{ + /// Gets the originating channel name (matches ). + public string Channel { get; } + + /// Gets the operation kind: "message.create", "command.invoke", ... + public string Operation { get; } + + /// Gets or sets the target input: string, , sequence, or workflow input. + public object Input { get; set; } + + /// Gets or sets the session hint. for ephemeral requests. + public ChannelSession? Session { get; set; } + + /// Gets or sets the channel-native user identity. Request metadata only; not a linking, authorization, or delivery key. + public ChannelIdentity? Identity { get; set; } + + /// Gets or sets the protocol-visible conversation / thread id, when distinct from . + public string? ConversationId { get; set; } + + /// Gets or sets the caller-derived chat options forwarded onto the runner's . + public ChatOptions? Options { get; set; } + + /// Gets or sets how the host resolves session continuity for this request. + public SessionMode SessionMode { get; set; } = SessionMode.Auto; + + /// Gets or sets the protocol-level metadata for telemetry. The host never reads this. + public IReadOnlyDictionary Metadata { get; set; } = ImmutableDictionary.Empty; + + /// + /// Gets or sets the channel-specific structured values surfaced to the run hook. Reserved key for workflow targets: + /// "workflow.checkpoint_id" (caller-supplied checkpoint resume). + /// + public IReadOnlyDictionary Attributes { get; set; } = ImmutableDictionary.Empty; + + /// Gets or sets a value indicating whether the channel is calling rather than . + public bool Stream { get; set; } + + /// Initializes a new instance of . + /// Originating channel name (matches ). + /// Operation kind: "message.create", "command.invoke", ... + /// Target input: string, , sequence, or workflow input. + public ChannelRequest(string channel, string operation, object input) + { + this.Channel = Throw.IfNullOrEmpty(channel); + this.Operation = Throw.IfNullOrEmpty(operation); + this.Input = Throw.IfNull(input); + } + + /// Initializes a new instance of by copying an existing instance. + /// The instance to copy. + public ChannelRequest(ChannelRequest other) + { + Throw.IfNull(other); + this.Channel = other.Channel; + this.Operation = other.Operation; + this.Input = other.Input; + this.Session = other.Session; + this.Identity = other.Identity; + this.ConversationId = other.ConversationId; + this.Options = other.Options; + this.SessionMode = other.SessionMode; + this.Metadata = other.Metadata; + this.Attributes = other.Attributes; + this.Stream = other.Stream; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs new file mode 100644 index 00000000000..cc00e16e240 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Context passed to . Runs after target invocation and +/// before the originating channel serializes its response. +/// +public sealed class ChannelResponseContext +{ + /// Gets the originating request. + public ChannelRequest Request { get; } + + /// Gets the originating channel name. + public string ChannelName { get; } + + /// Initializes a new instance of . + /// The originating request. + /// The originating channel name. + public ChannelResponseContext(ChannelRequest request, string channelName) + { + this.Request = Throw.IfNull(request); + this.ChannelName = Throw.IfNullOrEmpty(channelName); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs new file mode 100644 index 00000000000..95cb0c759f0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Per-call context passed to . +/// +public sealed class ChannelRunHookContext +{ + /// Gets the runner target: an , a workflow, or a hosted-agent handle. + public object Target { get; } + + /// Gets or sets the raw inbound payload as it arrived on the wire. Loosely typed. + public object? ProtocolRequest { get; set; } + + /// Initializes a new instance of . + /// The runner target: an , a workflow, or a hosted-agent handle. + public ChannelRunHookContext(object target) + { + this.Target = Throw.IfNull(target); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs new file mode 100644 index 00000000000..6c86b6ae026 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Session hint carried on a . All fields nullable to support +/// caller-supplied (Responses, Invocations) and host-tracked (Telegram, Activity Protocol) channels. +/// +public sealed class ChannelSession +{ + /// + /// Gets or sets the stable host lookup key for an . Caller-supplied channels populate + /// from the wire (previous_response_id, conversation_id); host-tracked channels + /// leave this and let the per-isolation-key alias decide. + /// + public string? Key { get; set; } + + /// Gets or sets the protocol-visible conversation or thread identifier when one exists. + public string? ConversationId { get; set; } + + /// Gets or sets the opaque isolation boundary (user, tenant, chat, ...) using hosted-agent terminology. + public string? IsolationKey { get; set; } + + /// Gets or sets the channel-defined attributes; not interpreted by the host. + public IReadOnlyDictionary Attributes { get; set; } = + ImmutableDictionary.Empty; + + /// Initializes a new instance of . + public ChannelSession() { } + + /// Initializes a new instance of by copying an existing instance. + /// The instance to copy. + public ChannelSession(ChannelSession other) + { + Throw.IfNull(other); + this.Key = other.Key; + this.ConversationId = other.ConversationId; + this.IsolationKey = other.IsolationKey; + this.Attributes = other.Attributes; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs new file mode 100644 index 00000000000..bb086d93f7d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable IDE0130 // Namespace does not match folder structure - intentional: extension methods live in the host framework namespace. +namespace Microsoft.AspNetCore.Builder; +#pragma warning restore IDE0130 + +/// +/// Extension methods on for mounting an agent-framework host. +/// +public static class EndpointRouteBuilderHostingChannelsExtensions +{ + /// + /// Mounts every registered channel's routes (rooted at each channel's path) and applies channel endpoint + /// filters. + /// + public static IEndpointConventionBuilder MapAgentFrameworkHost(this IEndpointRouteBuilder endpoints) + { + Throw.IfNull(endpoints); + + var host = endpoints.ServiceProvider.GetRequiredService(); + var registry = endpoints.ServiceProvider.GetRequiredService(); + var context = new ChannelContext(endpoints.ServiceProvider, host); + var hostGroup = endpoints.MapGroup(string.Empty); + + // Lift Foundry-provided isolation headers into IsolationKeys.Current per request, but only when the + // Foundry hosting environment flag is present. Absent the flag the raw headers are ignored. + IEndpointFilter? isolationFilter = IsFoundryHostingEnvironment() ? new IsolationKeysEndpointFilter() : null; + + foreach (var channel in host.Channels) + { + var contribution = channel.Contribute(context); + var channelGroup = string.IsNullOrEmpty(channel.Path) ? hostGroup : endpoints.MapGroup(channel.Path); + + registry.Add(contribution.OnStartup, contribution.OnShutdown); + + if (isolationFilter is not null) + { + channelGroup.AddEndpointFilter(isolationFilter); + } + + foreach (var filter in contribution.EndpointFilters) + { + channelGroup.AddEndpointFilter(filter); + } + + foreach (var register in contribution.Routes) + { + register(channelGroup); + } + } + + return hostGroup; + } + + private static bool IsFoundryHostingEnvironment() + => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("FOUNDRY_HOSTING_ENVIRONMENT")); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/FileHostStateStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/FileHostStateStore.cs new file mode 100644 index 00000000000..14f84e7d13d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/FileHostStateStore.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// File-system-backed . Persists reset-session aliases as one file per +/// isolation key and derives per-isolation-key workflow checkpoint directories. +/// +/// +/// On construction it acquires an exclusive single-owner OS lock on its root directory: a second store over +/// the same directory (in this or another process) fails fast rather than corrupting shared state. Dispose +/// the store to release the lock. +/// +public sealed class FileHostStateStore : IHostStateStore, IDisposable +{ + private readonly ConcurrentDictionary _aliasCache = new(StringComparer.Ordinal); + private readonly string _aliasesPath; + private readonly string _checkpointsPath; + private readonly object _gate = new(); + private readonly FileStream _lock; + + /// Initializes a new instance. + public FileHostStateStore(HostStatePathOptions paths) + { + Throw.IfNull(paths); + var root = paths.Root ?? "./.afhost"; + this._aliasesPath = paths.AliasesPath ?? Path.Combine(root, "aliases"); + this._checkpointsPath = paths.CheckpointsPath ?? Path.Combine(root, "checkpoints"); + Directory.CreateDirectory(root); + Directory.CreateDirectory(this._aliasesPath); + Directory.CreateDirectory(this._checkpointsPath); + + var lockPath = Path.Combine(root, ".lock"); + try + { + this._lock = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException ex) + { + throw new InvalidOperationException( + $"Another process already holds the hosting state lock at '{lockPath}'. Point each host at its own state directory.", + ex); + } + } + + /// + public ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(isolationKey); + var alias = Guid.NewGuid().ToString("N"); + this._aliasCache[isolationKey] = alias; + lock (this._gate) + { + File.WriteAllText(this.AliasFile(isolationKey), alias); + } + return default; + } + + /// + public ValueTask GetActiveSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(isolationKey); + if (this._aliasCache.TryGetValue(isolationKey, out var cached)) + { + return new(cached); + } + + var file = this.AliasFile(isolationKey); + string alias; + lock (this._gate) + { + alias = File.Exists(file) ? File.ReadAllText(file) : isolationKey; + if (!File.Exists(file)) + { + File.WriteAllText(file, alias); + } + } + this._aliasCache[isolationKey] = alias; + return new(alias); + } + + /// + public ValueTask GetCheckpointLocationAsync(string isolationKey, CancellationToken cancellationToken) + { + ValidateIsolationKey(isolationKey); + var dir = Path.Combine(this._checkpointsPath, EncodeFileName(isolationKey)); + Directory.CreateDirectory(dir); + return new(dir); + } + + /// + public void Dispose() => this._lock.Dispose(); + + private string AliasFile(string isolationKey) => Path.Combine(this._aliasesPath, EncodeFileName(isolationKey) + ".txt"); + + private static string EncodeFileName(string raw) => Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(raw)); + + /// + /// Reject isolation keys that could escape the checkpoint root (CWE-22). Mirrors the Python host's + /// denylist: no path separators or NUL, not dot-only, not absolute, no drive-letter prefix. Legitimate + /// namespaced keys (e.g. telegram:42) are preserved. + /// + private static void ValidateIsolationKey(string isolationKey) + { + Throw.IfNullOrEmpty(isolationKey); + + var invalid = + isolationKey.IndexOf('/') >= 0 || + isolationKey.IndexOf('\\') >= 0 || + isolationKey.IndexOf('\0') >= 0 || + isolationKey.Trim('.').Length == 0 || + Path.IsPathRooted(isolationKey) || + (isolationKey.Length >= 2 && char.IsLetter(isolationKey[0]) && isolationKey[1] == ':'); + + if (invalid) + { + throw new ArgumentException($"Invalid isolation key for checkpoint path: '{isolationKey}'.", nameof(isolationKey)); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs new file mode 100644 index 00000000000..1427e5c3927 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extension methods on for composing an +/// with channels. +/// +public static class HostApplicationBuilderHostingChannelsExtensions +{ + /// Adds an agent-framework host whose target is the supplied . + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, + AIAgent target, + Action? configure = null) + { + Throw.IfNull(builder); + Throw.IfNull(target); + return AddCore(builder, configure, services => services.TryAddSingleton(sp => new AIAgentRunner(target, sp.GetRequiredService()))); + } + + /// Adds an agent-framework host whose target is the supplied . + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, + Workflow target, + Action? configure = null) + { + Throw.IfNull(builder); + Throw.IfNull(target); + return AddCore(builder, configure, services => services.TryAddSingleton(sp => new WorkflowRunner(target, sp.GetRequiredService()))); + } + + /// Adds an agent-framework host whose target is resolved from a factory (for alternative runners). + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, + Func targetFactory, + Action? configure = null) + where TTarget : class + { + Throw.IfNull(builder); + Throw.IfNull(targetFactory); + return AddCore(builder, configure, services => services.TryAddSingleton(targetFactory)); + } + + private static AgentFrameworkHostBuilder AddCore( + IHostApplicationBuilder builder, + Action? configure, + Action registerTarget) + { + var options = new AgentFrameworkHostOptions(); + configure?.Invoke(options); + + var services = builder.Services; + + if (options.StatePaths is not null) + { + services.TryAddSingleton(_ => new FileHostStateStore(options.StatePaths)); + } + else + { + services.TryAddSingleton(_ => new InMemoryHostStateStore()); + } + + services.TryAddSingleton(); + services.AddHostedService(); + + services.TryAddSingleton(); + + registerTarget(services); + + services.TryAddSingleton(options); + services.TryAddSingleton(sp => new AgentFrameworkHost( + sp, + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + return new AgentFrameworkHostBuilder(services, options); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs new file mode 100644 index 00000000000..9261811a4e9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// File-system layout for . All paths optional; per-component paths +/// derive from when unset. v1 owns only reset-session aliases and workflow checkpoint +/// path derivation. +/// +public sealed class HostStatePathOptions +{ + /// Gets or sets the root directory under which per-component subpaths are derived. + public string? Root { get; set; } + + /// Gets or sets the path for reset-session aliases. + public string? AliasesPath { get; set; } + + /// Gets or sets the root path for per-isolation-key workflow checkpoint derivation. + public string? CheckpointsPath { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs new file mode 100644 index 00000000000..d485ae406ad --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Non-generic base for run results so channels and the host can pass them through capability +/// interfaces without committing to a result type. Inspect +/// or cast to for typed access. +/// +public abstract class HostedRunResult +{ + /// Gets or sets the session attached to this result, when present. + public ChannelSession? Session { get; set; } + + /// Gets the wrapped result as a boxed object. + public abstract object? ResultObject { get; } +} + +/// +/// Typed run result envelope. is AgentRunResponse for +/// agent targets and WorkflowRunResponse for workflow targets. +/// +public sealed class HostedRunResult : HostedRunResult +{ + /// Gets the typed result. + public TResult Result { get; } + + /// + public override object? ResultObject => this.Result; + + /// Initializes a new instance of . + /// The typed result. + public HostedRunResult(TResult result) + { + this.Result = result; + } + + /// + /// Shallow clone with a rewritten ; used by per-destination + /// rebinds. + /// + public HostedRunResult Replace(TNew newResult) => + new(newResult) { Session = this.Session }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs new file mode 100644 index 00000000000..b5f4083a016 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Base type for items yielded by . +/// Covers both normalized agent updates () and protocol-specific +/// events () behind one stream type. +/// +/// +/// is always the terminal item and carries the final +/// for downstream bookkeeping (intended-targets envelope, +/// durable push scheduling). +/// +public abstract class HostedStreamItem +{ + private protected HostedStreamItem() { } +} + +/// Normalized agent stream update; lossless for messages, function calls, usage. +public sealed class HostedStreamUpdate : HostedStreamItem +{ + /// Gets the agent response update. + public AgentResponseUpdate Update { get; } + + /// Initializes a new instance of . + /// The agent response update. + public HostedStreamUpdate(AgentResponseUpdate update) + { + this.Update = update; + } +} + +/// Protocol-specific event the framework does not model (workflow events, AG-UI events). +public sealed class HostedStreamEvent : HostedStreamItem +{ + /// Gets the protocol-specific event. + public object Event { get; } + + /// Initializes a new instance of . + /// The protocol-specific event. + public HostedStreamEvent(object @event) + { + this.Event = @event; + } +} + +/// Terminal item carrying the final result for post-stream bookkeeping. +public sealed class HostedStreamCompleted : HostedStreamItem +{ + /// Gets the final run result. + public HostedRunResult Result { get; } + + /// Initializes a new instance of . + /// The final run result. + public HostedStreamCompleted(HostedRunResult result) + { + this.Result = result; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IAgentFrameworkHostBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IAgentFrameworkHostBuilder.cs new file mode 100644 index 00000000000..b8eb83c659d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IAgentFrameworkHostBuilder.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Builder chained off AddAgentFrameworkHost(...). Channel-add extension methods +/// (AddResponsesChannel, ...) hang off this interface. +/// +public interface IAgentFrameworkHostBuilder +{ + /// Underlying service collection. + IServiceCollection Services { get; } + + /// Composition-time options. + AgentFrameworkHostOptions Options { get; } + + /// Add a channel instance. + IAgentFrameworkHostBuilder AddChannel(Channel channel); + + /// Add a channel resolved from DI via a factory. + IAgentFrameworkHostBuilder AddChannel(Func factory) where TChannel : Channel; + + /// Replace the registered host state store. + IAgentFrameworkHostBuilder UseHostStateStore<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TStore>() + where TStore : class, IHostStateStore; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelResponseHook.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelResponseHook.cs new file mode 100644 index 00000000000..a5186f7e5f1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelResponseHook.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Capability interface: receive a per-destination clone of the run result and return a possibly +/// rewritten replacement. Hooks rebind via +/// rather than mutating in place. +/// +public interface IChannelResponseHook +{ + /// Return the (possibly rewritten) result for this destination. + ValueTask OnResponseAsync( + HostedRunResult result, + ChannelResponseContext context, + CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelRunHook.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelRunHook.cs new file mode 100644 index 00000000000..3ebd0ab0023 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelRunHook.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Capability interface: rewrite the after the channel produces its +/// default envelope and before the host calls the runner. The canonical adapter point for +/// workflow targets and prompt rewriting. +/// +public interface IChannelRunHook +{ + /// Return the (possibly rewritten) request. + ValueTask OnRequestAsync( + ChannelRequest request, + ChannelRunHookContext context, + CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelStreamTransformHook.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelStreamTransformHook.cs new file mode 100644 index 00000000000..8aef70c033a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelStreamTransformHook.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Capability interface: rewrite the upstream agent-update stream before it is projected onto +/// the channel wire. Hooks may filter, debounce, or annotate updates. +/// +public interface IChannelStreamTransformHook +{ + /// Wrap the upstream stream with the transform. + IAsyncEnumerable TransformAsync( + IAsyncEnumerable upstream, + CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostStateStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostStateStore.cs new file mode 100644 index 00000000000..7a06fb638e4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostStateStore.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Host-execution metadata store, limited to v1 scope: reset-session aliases and workflow checkpoint path +/// derivation. It does NOT store linked identities, active-channel state, response routing, continuation +/// records, durable runner queues, or delivery attempts (those are ADR-0028 concerns). Separate from +/// AgentSessionStore (per-conversation history) and WorkflowBuilder.CheckpointStorage +/// (workflow checkpoints). +/// +public interface IHostStateStore +{ + /// + /// Rotate the active session-id alias for an isolation key. Backs + /// for host-tracked channels' /new-style commands. + /// + ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken cancellationToken); + + /// Read the active session-id alias for an isolation key. + ValueTask GetActiveSessionAliasAsync(string isolationKey, CancellationToken cancellationToken); + + /// + /// Derive the persistent workflow checkpoint location for an isolation key, or + /// when this store does not persist checkpoints (e.g. the in-memory store). Implementations must reject + /// path-traversal patterns in the isolation key. + /// + ValueTask GetCheckpointLocationAsync(string isolationKey, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostedTargetRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostedTargetRunner.cs new file mode 100644 index 00000000000..271a8dbea3b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostedTargetRunner.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Pluggable adapter that drives the actual target (AI agent, workflow, Foundry hosted agent). +/// Channels never branch on target type; they go through this seam. +/// +public interface IHostedTargetRunner +{ + /// Run the target. + ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken); + + /// Stream the target's response. + IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InMemoryHostStateStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InMemoryHostStateStore.cs new file mode 100644 index 00000000000..b49308b52f2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InMemoryHostStateStore.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// In-memory . Volatile; intended for tests, samples, and single-process +/// development. Thread-safe. +/// +public sealed class InMemoryHostStateStore : IHostStateStore +{ + private readonly ConcurrentDictionary _sessionAliases = new(StringComparer.Ordinal); + + /// + public ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(isolationKey); + this._sessionAliases[isolationKey] = Guid.NewGuid().ToString("N"); + return default; + } + + /// + public ValueTask GetActiveSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(isolationKey); + if (!this._sessionAliases.TryGetValue(isolationKey, out var alias)) + { + alias = isolationKey; + this._sessionAliases.TryAdd(isolationKey, alias); + } + return new(alias); + } + + /// + public ValueTask GetCheckpointLocationAsync(string isolationKey, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(isolationKey); + + // The in-memory store does not persist checkpoints, so there is no on-disk location to derive. + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/AgentFrameworkHostBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/AgentFrameworkHostBuilder.cs new file mode 100644 index 00000000000..78445ffe194 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/AgentFrameworkHostBuilder.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +internal sealed class AgentFrameworkHostBuilder : IAgentFrameworkHostBuilder +{ + private readonly List _channels = []; + + public AgentFrameworkHostBuilder(IServiceCollection services, AgentFrameworkHostOptions options) + { + this.Services = Throw.IfNull(services); + this.Options = Throw.IfNull(options); + services.AddSingleton>(_ => this._channels); + } + + public IServiceCollection Services { get; } + + public AgentFrameworkHostOptions Options { get; } + + public IAgentFrameworkHostBuilder AddChannel(Channel channel) + { + Throw.IfNull(channel); + channel.ConfigureServices(this.Services); + this._channels.Add(channel); + return this; + } + + public IAgentFrameworkHostBuilder AddChannel(Func factory) where TChannel : Channel + { + Throw.IfNull(factory); + + using var probeProvider = this.Services.BuildServiceProvider(); + var probe = factory(probeProvider); + probe.ConfigureServices(this.Services); + + this.Services.AddSingleton(factory); + this.Services.AddSingleton(sp => sp.GetRequiredService()); + this._channels.Add(probe); + return this; + } + + public IAgentFrameworkHostBuilder UseHostStateStore<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TStore>() + where TStore : class, IHostStateStore + { + this.Services.Replace(ServiceDescriptor.Singleton()); + return this; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleRegistry.cs new file mode 100644 index 00000000000..32451a8a7f1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleRegistry.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Collects the / +/// callbacks gathered while MapAgentFrameworkHost invokes each channel's Contribute, so the +/// can invoke them at application start / stop. Singleton. +/// +internal sealed class ChannelLifecycleRegistry +{ + private readonly object _gate = new(); + private readonly List> _startup = []; + private readonly List> _shutdown = []; + + public void Add(Func? onStartup, Func? onShutdown) + { + lock (this._gate) + { + if (onStartup is not null) + { + this._startup.Add(onStartup); + } + + if (onShutdown is not null) + { + this._shutdown.Add(onShutdown); + } + } + } + + public IReadOnlyList> StartupCallbacks + { + get { lock (this._gate) { return [.. this._startup]; } } + } + + public IReadOnlyList> ShutdownCallbacks + { + get { lock (this._gate) { return [.. this._shutdown]; } } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleService.cs new file mode 100644 index 00000000000..707219d2b9e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleService.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Invokes channel callbacks when the application starts and +/// callbacks (in reverse registration order) when it stops. +/// Registered by AddAgentFrameworkHost; reads callbacks recorded by MapAgentFrameworkHost. +/// +internal sealed class ChannelLifecycleService : IHostedService +{ + private readonly ChannelLifecycleRegistry _registry; + + public ChannelLifecycleService(ChannelLifecycleRegistry registry) + { + this._registry = Throw.IfNull(registry); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + foreach (var callback in this._registry.StartupCallbacks) + { + await callback(cancellationToken).ConfigureAwait(false); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + var callbacks = this._registry.ShutdownCallbacks; + for (var i = callbacks.Count - 1; i >= 0; i--) + { + await callbacks[i](cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/IsolationKeysEndpointFilter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/IsolationKeysEndpointFilter.cs new file mode 100644 index 00000000000..f48ccb5d3a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/IsolationKeysEndpointFilter.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Endpoint filter that lifts the Foundry x-agent-user-isolation-key / x-agent-chat-isolation-key +/// request headers into for the duration of the request and resets it +/// afterwards. +/// +/// +/// The host applies this filter to every channel route only when the Foundry hosting environment flag is +/// present. When neither header is supplied the request passes through untouched, so local-development and +/// non-Foundry requests behave as if the filter were absent. +/// +internal sealed class IsolationKeysEndpointFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var headers = context.HttpContext.Request.Headers; + var userKey = headers[IsolationKeys.UserHeader].ToString(); + var chatKey = headers[IsolationKeys.ChatHeader].ToString(); + + if (string.IsNullOrEmpty(userKey) && string.IsNullOrEmpty(chatKey)) + { + return await next(context).ConfigureAwait(false); + } + + var previous = IsolationKeys.Current; + IsolationKeys.Current = new IsolationKeys( + string.IsNullOrEmpty(userKey) ? null : userKey, + string.IsNullOrEmpty(chatKey) ? null : chatKey); + try + { + return await next(context).ConfigureAwait(false); + } + finally + { + IsolationKeys.Current = previous; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs new file mode 100644 index 00000000000..6cedc652fb0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Per-request partition hints the Foundry Hosted Agents runtime injects on requests it forwards to the +/// container, lifted off the x-agent-user-isolation-key / x-agent-chat-isolation-key headers. +/// +/// +/// Distinct from the app-level . Consuming providers read +/// (or inject ) so individual channels do not each +/// have to parse the platform headers. The host installs the lifting filter only when the Foundry hosting +/// environment flag is present; absent the flag the raw headers are ignored and stays +/// . +/// +public sealed class IsolationKeys +{ + /// Header name carrying the opaque per-user partition key. + public const string UserHeader = "x-agent-user-isolation-key"; + + /// Header name carrying the opaque per-conversation partition key. + public const string ChatHeader = "x-agent-chat-isolation-key"; + + private static readonly AsyncLocal s_current = new(); + + /// Initializes a new instance of the class. + /// The per-user partition key, or . + /// The per-conversation partition key, or . + public IsolationKeys(string? userKey, string? chatKey) + { + this.UserKey = userKey; + this.ChatKey = chatKey; + } + + /// Gets the per-user partition key, when present. + public string? UserKey { get; } + + /// Gets the per-conversation partition key, when present. + public string? ChatKey { get; } + + /// Gets a value indicating whether both keys are . + public bool IsEmpty => this.UserKey is null && this.ChatKey is null; + + /// Gets or sets the current per-request keys for the executing async flow. + public static IsolationKeys? Current + { + get => s_current.Value; + set => s_current.Value = value; + } +} + +/// Dependency-injection accessor over for testable consumers. +public interface IIsolationKeysAccessor +{ + /// Gets the current per-request keys, or when none were lifted. + IsolationKeys? Current { get; } +} + +internal sealed class IsolationKeysAccessor : IIsolationKeysAccessor +{ + public IsolationKeys? Current => IsolationKeys.Current; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Microsoft.Agents.AI.Hosting.Channels.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Microsoft.Agents.AI.Hosting.Channels.csproj new file mode 100644 index 00000000000..bc6f155a679 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Microsoft.Agents.AI.Hosting.Channels.csproj @@ -0,0 +1,36 @@ + + + + $(TargetFrameworksCore) + Microsoft.Agents.AI.Hosting.Channels + alpha + + + + + + true + + + + + + + + + + + + + + + + + + + + + Microsoft Agent Framework Hosting Channels + Provides the multi-channel hosting surface for the Microsoft Agent Framework: composable Channel contract, identity / link-policy pipeline, host state store, and durable task runner. + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs new file mode 100644 index 00000000000..4d89d4a5729 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Default for targets. Coerces +/// into the agent's RunAsync message shape, resolves an +/// from the request's isolation key (cached per active session alias), and wraps +/// the response in . +/// +/// +/// Session continuity is the ADR-0027 core feature: identical +/// values resolve to the same cached ; +/// rotates the alias so the next run starts a fresh session. Cache is in-process for this slice. +/// +public sealed class AIAgentRunner : IHostedTargetRunner +{ + private readonly AIAgent _agent; + private readonly IHostStateStore _stateStore; + private readonly ConcurrentDictionary> _sessions = new(StringComparer.Ordinal); + + /// Initializes a new instance. + public AIAgentRunner(AIAgent agent, IHostStateStore stateStore) + { + this._agent = Throw.IfNull(agent); + this._stateStore = Throw.IfNull(stateStore); + } + + /// + public async ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken) + { + Throw.IfNull(request); + var messages = CoerceToMessages(request.Input); + var session = await this.ResolveSessionAsync(request, cancellationToken).ConfigureAwait(false); + var runOptions = request.Options is null ? null : new ChatClientAgentRunOptions(request.Options); + var response = await this._agent.RunAsync(messages, session, runOptions, cancellationToken).ConfigureAwait(false); + return new HostedRunResult(response) + { + Session = request.Session, + }; + } + + /// + public async IAsyncEnumerable StreamAsync( + ChannelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + Throw.IfNull(request); + var messages = CoerceToMessages(request.Input); + var session = await this.ResolveSessionAsync(request, cancellationToken).ConfigureAwait(false); + var runOptions = request.Options is null ? null : new ChatClientAgentRunOptions(request.Options); + + AgentResponseUpdate? final = null; + await foreach (var update in this._agent.RunStreamingAsync(messages, session, runOptions, cancellationToken).ConfigureAwait(false)) + { + final = update; + yield return new HostedStreamUpdate(update); + } + + var aggregate = final is null + ? new HostedRunResult(null) { Session = request.Session } + : (HostedRunResult)new HostedRunResult(final) { Session = request.Session }; + yield return new HostedStreamCompleted(aggregate); + } + + private async ValueTask ResolveSessionAsync(ChannelRequest request, CancellationToken cancellationToken) + { + if (request.SessionMode == SessionMode.Disabled) + { + return null; + } + + var isolationKey = request.Session?.IsolationKey; + if (string.IsNullOrEmpty(isolationKey)) + { + if (request.SessionMode == SessionMode.Required) + { + throw new InvalidOperationException( + "SessionMode.Required: the request must carry a ChannelSession.IsolationKey to resolve or create a session, but none was supplied."); + } + + return null; + } + + var alias = await this._stateStore.GetActiveSessionAliasAsync(isolationKey, cancellationToken).ConfigureAwait(false) + ?? isolationKey; + + return await this._sessions.GetOrAdd(alias, _ => this._agent.CreateSessionAsync(CancellationToken.None).AsTask()).ConfigureAwait(false); + } + + private static ChatMessage[] CoerceToMessages(object input) + { + return input switch + { + string s => [new ChatMessage(ChatRole.User, s)], + ChatMessage cm => [cm], + IEnumerable seq => seq.ToArray(), + _ => throw new ArgumentException( + $"AIAgentRunner cannot coerce input of type '{input?.GetType().FullName ?? ""}' into ChatMessage[]. " + + "Channels should normalize input to string, ChatMessage, or IEnumerable before calling RunAsync.", + nameof(input)), + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs new file mode 100644 index 00000000000..9772a335b03 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Default for targets. Drives execution via +/// and projects pause / completion / failure into +/// . +/// +/// +/// When the host state store yields a persistent checkpoint location for the request's isolation key, the +/// runner enables per-isolation-key file checkpointing: each run is checkpointed under that directory, the +/// resulting checkpoint id is surfaced on the result session attributes ("workflow.checkpoint_id"), +/// and a subsequent request carrying that id on resumes from it. With +/// the in-memory store (no persistent location) the runner simply runs forward. +/// +public sealed class WorkflowRunner : IHostedTargetRunner +{ + /// Attribute key for caller-supplied checkpoint resume (and the surfaced resume token). + public const string CheckpointIdAttribute = "workflow.checkpoint_id"; + + private readonly IHostStateStore? _stateStore; + + /// Initializes a new instance. + public WorkflowRunner(Workflow workflow) : this(workflow, stateStore: null) + { + } + + /// Initializes a new instance with a host state store for per-isolation-key checkpointing. + public WorkflowRunner(Workflow workflow, IHostStateStore? stateStore) + { + this.Workflow = Throw.IfNull(workflow); + this._stateStore = stateStore; + } + + /// The wrapped workflow. + public Workflow Workflow { get; } + + /// + public async ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken) + { + Throw.IfNull(request); + var (run, store) = await this.OpenRunAsync(request, cancellationToken).ConfigureAwait(false); + try + { + var outputs = new List(); + ExternalRequest? pending = null; + + await foreach (var evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) + { + switch (evt) + { + case RequestInfoEvent rie: + // The workflow paused awaiting external input; stop consuming or the stream blocks. + pending = rie.Request; + goto done; + case AgentResponseUpdateEvent: + // Streaming delta from an agent-based workflow; not a terminal output. + break; + case WorkflowOutputEvent woe: + outputs.Add(woe.Data); + break; + case WorkflowErrorEvent err: + return Build(new WorkflowRunResult { Status = WorkflowRunStatus.Failed, Error = err.Data?.ToString(), Outputs = outputs, SessionId = run.SessionId }, request.Session); + } + } + +done: + return BuildResult(run, pending, outputs, request); + } + finally + { + store?.Dispose(); + } + } + + /// + public async IAsyncEnumerable StreamAsync( + ChannelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + Throw.IfNull(request); + var (run, store) = await this.OpenRunAsync(request, cancellationToken).ConfigureAwait(false); + try + { + var outputs = new List(); + ExternalRequest? pending = null; + + await foreach (var evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) + { + yield return new HostedStreamEvent(evt); + if (evt is RequestInfoEvent rie) + { + // The workflow paused awaiting external input; stop consuming or the stream blocks. + pending = rie.Request; + break; + } + + if (evt is WorkflowOutputEvent woe and not AgentResponseUpdateEvent) + { + outputs.Add(woe.Data); + } + } + + yield return new HostedStreamCompleted(BuildResult(run, pending, outputs, request)); + } + finally + { + store?.Dispose(); + } + } + + private async ValueTask<(StreamingRun Run, FileSystemJsonCheckpointStore? Store)> OpenRunAsync(ChannelRequest request, CancellationToken cancellationToken) + { + var sessionKey = request.Session?.IsolationKey ?? request.Session?.Key; + + string? location = null; + if (this._stateStore is not null && !string.IsNullOrEmpty(sessionKey)) + { + location = await this._stateStore.GetCheckpointLocationAsync(sessionKey!, cancellationToken).ConfigureAwait(false); + } + + if (location is null) + { + var plain = await InProcessExecution.OpenStreamingAsync(this.Workflow, request.Session?.Key, cancellationToken).ConfigureAwait(false); + await SendInputAsync(plain, request.Input).ConfigureAwait(false); + return (plain, null); + } + + var store = new FileSystemJsonCheckpointStore(new DirectoryInfo(location)); + var manager = CheckpointManager.CreateJson(store); + + if (request.Attributes.TryGetValue(CheckpointIdAttribute, out var raw) && raw is string checkpointId && !string.IsNullOrEmpty(checkpointId)) + { + // Rehydrate the workflow from the caller-supplied checkpoint, then apply the new input so the + // resumed run advances and produces output (mirrors Python's restore-then-run seam). + var resumed = await InProcessExecution.ResumeStreamingAsync(this.Workflow, new CheckpointInfo(sessionKey!, checkpointId), manager, cancellationToken).ConfigureAwait(false); + await SendInputAsync(resumed, request.Input).ConfigureAwait(false); + return (resumed, store); + } + + var run = await InProcessExecution.OpenStreamingAsync(this.Workflow, manager, sessionKey, cancellationToken).ConfigureAwait(false); + await SendInputAsync(run, request.Input).ConfigureAwait(false); + return (run, store); + } + + /// + /// Send the channel input to the run using its runtime type so the workflow's typed start executor + /// receives it. Passing directly to a generic run API would declare the + /// message type as , which a typed executor never matches. A subsequent + /// drives agent-based workflows (built via AgentWorkflowBuilder) to take a + /// turn and emit their outputs; it is harmlessly undelivered for plain executor workflows that have no + /// handler, so it is sent unconditionally for parity with Python's high-level + /// Workflow.run(message) seam. + /// + [UnconditionalSuppressMessage("Trimming", "IL2060:MakeGenericMethod", Justification = "Input is a reference type (string / ChatMessage list / workflow input); shared generics keep TrySendMessageAsync reachable.")] + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Hosting runner is not used in AOT scenarios; the workflow input type is a reference type.")] + private static async ValueTask SendInputAsync(StreamingRun run, object input) + { + var send = s_trySendMessage.MakeGenericMethod(input.GetType()); + await ((ValueTask)send.Invoke(run, [input])!).ConfigureAwait(false); + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); + } + + private static readonly MethodInfo s_trySendMessage = + typeof(StreamingRun).GetMethod(nameof(StreamingRun.TrySendMessageAsync))!; + + private static HostedRunResult BuildResult(StreamingRun run, ExternalRequest? pending, List outputs, ChannelRequest request) + { + var session = new ChannelSession(request.Session ?? new ChannelSession()) { Key = run.SessionId }; + + var checkpointId = run.LastCheckpoint?.CheckpointId; + if (checkpointId is not null) + { + session.Attributes = new Dictionary(session.Attributes) { [CheckpointIdAttribute] = checkpointId }; + } + + var result = pending is not null + ? new WorkflowRunResult { Status = WorkflowRunStatus.AwaitingInput, PendingRequest = pending, Outputs = outputs, SessionId = run.SessionId } + : new WorkflowRunResult { Status = WorkflowRunStatus.Completed, Outputs = outputs, SessionId = run.SessionId }; + return Build(result, session); + } + + private static HostedRunResult Build(WorkflowRunResult result, ChannelSession? session) => + new(result) { Session = session }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/SessionMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/SessionMode.cs new file mode 100644 index 00000000000..5fd8485175a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/SessionMode.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Controls how the host resolves an for a . +/// +public enum SessionMode +{ + /// Default. The host resolves a session if the channel supplies enough hints, otherwise runs ephemerally. + Auto, + + /// The host MUST resolve or create a session; missing required hints surface as an error. + Required, + + /// The host never resolves a session; the target runs ephemerally even if hints are present. + Disabled, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs new file mode 100644 index 00000000000..f2016ed9588 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Result envelope returned by . Carries the workflow output +/// list plus optional pause-state for HITL flows that emit a . +/// +/// +/// When is the channel renders +/// a status-awaiting_input envelope and the resume token lives on +/// attributes under the key "workflow.resume_token". +/// +public sealed class WorkflowRunResult +{ + /// Gets or sets the lifecycle status the runner reached when control returned. + public WorkflowRunStatus Status { get; set; } + + /// + /// Gets or sets the outputs emitted by the workflow (from WorkflowOutputEvent). Order matches event order. + /// + public IReadOnlyList Outputs { get; set; } = []; + + /// + /// Gets or sets the pending external request that paused execution. Populated when + /// is . + /// + public ExternalRequest? PendingRequest { get; set; } + + /// Gets or sets the workflow session id this run is associated with. + public string? SessionId { get; set; } + + /// Gets or sets the failure detail when is . + public string? Error { get; set; } +} + +/// Lifecycle status of a . +public enum WorkflowRunStatus +{ + /// The workflow ran to completion. + Completed, + + /// The workflow paused on a ; resume by passing the resume token. + AwaitingInput, + + /// The workflow run failed. + Failed, +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/AIAgentRunnerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/AIAgentRunnerTests.cs new file mode 100644 index 00000000000..c53b16253b8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/AIAgentRunnerTests.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Direct coverage of session-mode handling, including the ADR-0027 +/// contract that a usable session key must be present. +/// +public class AIAgentRunnerTests +{ + [Fact] + public async Task SessionModeRequired_WithoutKey_ThrowsAsync() + { + // Arrange + var runner = new AIAgentRunner(new EchoAgent(), new InMemoryHostStateStore()); + var request = new ChannelRequest("responses", "message.create", "hi") { SessionMode = SessionMode.Required }; + + // Act / Assert + await Assert.ThrowsAsync(async () => await runner.RunAsync(request, default)); + } + + [Fact] + public async Task SessionModeRequired_WithKey_RunsAsync() + { + // Arrange + var runner = new AIAgentRunner(new EchoAgent(), new InMemoryHostStateStore()); + var request = new ChannelRequest("responses", "message.create", "hi") + { + SessionMode = SessionMode.Required, + Session = new ChannelSession { IsolationKey = "alice" }, + }; + + // Act + var result = await runner.RunAsync(request, default); + + // Assert + Assert.NotNull(result.ResultObject); + } + + [Fact] + public async Task SessionModeDisabled_WithoutKey_RunsAsync() + { + // Arrange + var runner = new AIAgentRunner(new EchoAgent(), new InMemoryHostStateStore()); + var request = new ChannelRequest("responses", "message.create", "hi") { SessionMode = SessionMode.Disabled }; + + // Act + var result = await runner.RunAsync(request, default); + + // Assert + Assert.NotNull(result.ResultObject); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/AgentWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/AgentWorkflowTests.cs new file mode 100644 index 00000000000..46785101877 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/AgentWorkflowTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Verifies an agent workflow (AgentWorkflowBuilder.BuildSequential over multiple agents) hosted through the +/// Responses channel: the run hook adapts the parsed Responses input into the workflow's ChatMessage-list +/// input, the host's WorkflowRunner drives it, and a completed response is rendered over POST /responses. +/// No live model. +/// +public class AgentWorkflowTests +{ + [Fact] + public async Task SequentialAgentWorkflow_RunsThroughResponsesChannelAsync() + { + // Arrange - two deterministic agents chained sequentially + var workflow = AgentWorkflowBuilder.BuildSequential(new EchoAgent(), new FakeChatAgent()); + + await using var app = await TestHostApp.StartAsync(b => b + .AddAgentFrameworkHost(workflow) + .AddResponsesChannel(o => o.RunHook = new ChatMessageListRunHook())); + + // Act + var response = await app.Client.PostAsync( + new System.Uri("http://localhost/responses"), + Json("{ \"input\": \"hello\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - the workflow ran to completion and projected the agents' chained replies + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var doc = JsonDocument.Parse(body); + Assert.Equal("completed", doc.RootElement.GetProperty("status").GetString()); + + // The terminal WorkflowOutputEvent carries the chained ChatMessage list; FlattenWorkflowOutputs + // projects it, so the last agent's reply text renders into a message output item. + var rendered = false; + foreach (var item in doc.RootElement.GetProperty("output").EnumerateArray()) + { + if (item.GetProperty("type").GetString() != "message") + { + continue; + } + + foreach (var content in item.GetProperty("content").EnumerateArray()) + { + if (content.TryGetProperty("text", out var text) && + text.GetString()?.Contains(FakeChatAgent.Reply, System.StringComparison.Ordinal) == true) + { + rendered = true; + } + } + } + + Assert.True(rendered, $"expected rendered output to contain '{FakeChatAgent.Reply}'. Body: {body}"); + } + + private static StringContent Json(string json) => new(json, System.Text.Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ChannelContractTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ChannelContractTests.cs new file mode 100644 index 00000000000..505b25d5ad0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ChannelContractTests.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Exercises the channel/host contract directly: route aggregation under , +/// lifecycle callbacks, endpoint filters, the run/stream invocation seam, hook ordering, multi-channel +/// composition, and target neutrality. +/// +public class ChannelContractTests +{ + [Fact] + public async Task Routes_MountUnderChannelPathAsync() + { + // Arrange + var probe = new ProbeChannel(path: "/probe"); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(probe)); + + // Act + var ok = await app.Client.GetAsync(new System.Uri("http://localhost/probe/ping")); + var miss = await app.Client.GetAsync(new System.Uri("http://localhost/ping")); + + // Assert + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + Assert.Equal("pong", await ok.Content.ReadAsStringAsync()); + Assert.Equal(HttpStatusCode.NotFound, miss.StatusCode); + } + + [Fact] + public async Task CustomPath_IsHonoredAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new ProbeChannel(path: "/probe2"))); + + // Act + var moved = await app.Client.GetAsync(new System.Uri("http://localhost/probe2/ping")); + var old = await app.Client.GetAsync(new System.Uri("http://localhost/probe/ping")); + + // Assert + Assert.Equal(HttpStatusCode.OK, moved.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, old.StatusCode); + } + + [Fact] + public async Task Lifecycle_StartupAndShutdownCallbacksFireAsync() + { + // Arrange + var probe = new ProbeChannel(); + var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(probe)); + + // Assert - startup fired during StartAsync + Assert.True(probe.StartupFired); + Assert.False(probe.ShutdownFired); + + // Act - dispose stops the host + await app.DisposeAsync(); + + // Assert - shutdown fired during StopAsync + Assert.True(probe.ShutdownFired); + } + + [Fact] + public async Task EndpointFilter_AppliedToChannelGroupAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new ProbeChannel())); + + // Act + var response = await app.Client.GetAsync(new System.Uri("http://localhost/probe/ping")); + + // Assert + Assert.True(response.Headers.TryGetValues("x-probe-filter", out var values)); + Assert.Contains("applied", values!); + } + + [Fact] + public async Task RunSeam_InvokesTargetAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new ProbeChannel())); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/probe/run"), new StringContent("hi")); + + // Assert + Assert.Equal(FakeChatAgent.Reply, await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task StreamSeam_YieldsUpdatesThenOneCompletedAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new ProbeChannel())); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/probe/stream"), new StringContent("hi")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - 8 update chunks, exactly one completed terminal + Assert.Equal("updates=8;completed=1", body); + } + + [Fact] + public async Task MultipleChannels_ShareOneHostAndTargetAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b + .AddAgentFrameworkHost(new FakeChatAgent()) + .AddChannel(new ProbeChannel()) + .AddResponsesChannel()); + + // Act + var probe = await app.Client.PostAsync(new System.Uri("http://localhost/probe/run"), new StringContent("hi")); + var responses = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), JsonBody("{ \"input\": \"hi\" }")); + + // Assert - both channels reachable, both hit the same fake target + Assert.Equal(HttpStatusCode.OK, probe.StatusCode); + Assert.Equal(FakeChatAgent.Reply, await probe.Content.ReadAsStringAsync()); + Assert.Equal(HttpStatusCode.OK, responses.StatusCode); + Assert.Contains(FakeChatAgent.Reply, await responses.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task TargetNeutrality_SameChannelWorksForAgentAndWorkflowAsync() + { + // Arrange + Act - agent target + await using (var agentApp = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new ProbeChannel()))) + { + var agentResponse = await agentApp.Client.PostAsync(new System.Uri("http://localhost/probe/run"), new StringContent("hi")); + Assert.Equal(HttpStatusCode.OK, agentResponse.StatusCode); + } + + // Arrange + Act - workflow target, identical channel + var workflow = WorkflowFactory.Echo(); + await using var workflowApp = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(workflow).AddChannel(new ProbeChannel())); + var workflowResponse = await workflowApp.Client.PostAsync(new System.Uri("http://localhost/probe/run"), new StringContent("hi")); + + // Assert - the same probe channel drove a workflow target without branching on type + Assert.Equal(HttpStatusCode.OK, workflowResponse.StatusCode); + } + + private static StringContent JsonBody(string json) => new(json, System.Text.Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/FunctionCallingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/FunctionCallingTests.cs new file mode 100644 index 00000000000..c476b957a49 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/FunctionCallingTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Verifies function-calling support end to end through the Responses channel: a ChatClientAgent built with +/// a tool over a deterministic two-turn fake chat client executes the tool (via the framework's +/// FunctionInvokingChatClient) and renders the final answer over POST /responses. No live model. +/// +public class FunctionCallingTests +{ + [Fact] + public async Task ParameterlessTool_IsInvoked_AndFinalAnswerRenderedAsync() + { + // Arrange - a parameterless server-side tool the framework executes during the function-call loop + var toolCalled = false; + var weatherTool = AIFunctionFactory.Create( + () => { toolCalled = true; return "sunny"; }, + name: "get_weather", + description: "Gets the weather."); + + AIAgent agent = new ChatClientAgent( + new FakeFunctionCallingChatClient(), + instructions: "You are a weather assistant.", + name: "weather", + tools: [weatherTool]); + + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(agent).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync( + new System.Uri("http://localhost/responses"), + Json("{ \"input\": \"What is the weather in Seattle?\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - tool executed and the post-tool final answer was rendered + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(toolCalled, "the server-side tool should have been invoked by the function-calling loop"); + + using var doc = JsonDocument.Parse(body); + Assert.Equal("completed", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal(FakeFunctionCallingChatClient.FinalAnswer, MessageText(doc)); + } + + [Fact] + public async Task ParameterizedTool_ReceivesArgument_AndFinalAnswerRenderedAsync() + { + // Arrange - a tool with a required parameter; the fake supplies the argument on the function call + string? capturedCity = null; + var weatherTool = AIFunctionFactory.Create( + (string city) => { capturedCity = city; return $"sunny in {city}"; }, + name: "get_weather", + description: "Gets the weather for a city."); + + var chatClient = new FakeFunctionCallingChatClient(new Dictionary { ["city"] = "Seattle" }); + AIAgent agent = new ChatClientAgent(chatClient, instructions: null, name: "weather", tools: [weatherTool]); + + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(agent).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync( + new System.Uri("http://localhost/responses"), + Json("{ \"input\": \"What is the weather in Seattle?\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - the argument bound and reached the tool, and the final answer was rendered + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Seattle", capturedCity); + + using var doc = JsonDocument.Parse(body); + Assert.Equal("completed", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal(FakeFunctionCallingChatClient.FinalAnswer, MessageText(doc)); + // The function call is rendered as its own output item (rich rendering parity). + Assert.Contains(doc.RootElement.GetProperty("output").EnumerateArray(), o => o.GetProperty("type").GetString() == "function_call"); + } + + [Fact] + public async Task ToolCalling_StreamsFinalAnswerAsync() + { + // Arrange + var weatherTool = AIFunctionFactory.Create(() => "sunny", name: "get_weather", description: "Gets the weather."); + AIAgent agent = new ChatClientAgent(new FakeFunctionCallingChatClient(), instructions: null, name: "weather", tools: [weatherTool]); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(agent).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync( + new System.Uri("http://localhost/responses"), + Json("{ \"input\": \"weather in Seattle?\", \"stream\": true }")); + var body = await response.Content.ReadAsStringAsync(); + var frames = Sse.Parse(body); + + // Assert - the final answer (after the tool loop) is delivered over SSE + Assert.Equal("response.created", System.Linq.Enumerable.First(frames.Events())); + Assert.Equal("response.completed", System.Linq.Enumerable.Last(frames.Events())); + Assert.Contains(FakeFunctionCallingChatClient.FinalAnswer, body); + } + + private static string? MessageText(JsonDocument doc) + { + foreach (var item in doc.RootElement.GetProperty("output").EnumerateArray()) + { + if (item.GetProperty("type").GetString() == "message") + { + return item.GetProperty("content")[0].GetProperty("text").GetString(); + } + } + + return null; + } + + private static StringContent Json(string json) => new(json, System.Text.Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/HookTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/HookTests.cs new file mode 100644 index 00000000000..dfb97403878 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/HookTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Verifies the host-owned hook pipeline on the Responses channel: run hook runs before the target, +/// response hook runs before serialization, and the stream-transform hook is applied while streaming. +/// +public class HookTests +{ + [Fact] + public async Task RunHook_RewritesInputBeforeTargetAsync() + { + // Arrange - echo agent reflects whatever input the target receives; run hook rewrites it + await using var app = await TestHostApp.StartAsync(b => b + .AddAgentFrameworkHost(new EchoAgent()) + .AddResponsesChannel(o => o.RunHook = new RewriteInputRunHook("REWRITTEN"))); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ \"input\": \"original\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - target saw the rewritten input + Assert.Contains("REWRITTEN", body); + Assert.DoesNotContain("original", body); + } + + [Fact] + public async Task ResponseHook_RewritesResultBeforeSerializeAsync() + { + // Arrange - fake agent replies "Hello from fake agent!"; response hook uppercases it + await using var app = await TestHostApp.StartAsync(b => b + .AddAgentFrameworkHost(new FakeChatAgent()) + .AddResponsesChannel(o => o.ResponseHook = new UppercaseResponseHook())); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ \"input\": \"hi\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Contains(FakeChatAgent.Reply.ToUpperInvariant(), body); + } + + [Fact] + public async Task StreamTransformHook_AppliedWhileStreamingAsync() + { + // Arrange - prefix hook injects a "[X]" chunk ahead of the agent stream + await using var app = await TestHostApp.StartAsync(b => b + .AddAgentFrameworkHost(new FakeChatAgent()) + .AddResponsesChannel(o => o.StreamTransformHook = new PrefixStreamHook())); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ \"input\": \"hi\", \"stream\": true }")); + var body = await response.Content.ReadAsStringAsync(); + var frames = Sse.Parse(body); + + // Assert - first delta is the injected marker + Assert.Contains("[X]", body); + var firstDelta = System.Array.Find(System.Linq.Enumerable.ToArray(frames), f => f.Event == "response.output_text.delta"); + Assert.Contains("[X]", firstDelta.Data); + } + + private static StringContent Json(string json) => new(json, System.Text.Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/IsolationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/IsolationTests.cs new file mode 100644 index 00000000000..835e0d9ef69 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/IsolationTests.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// End-to-end coverage of the Foundry isolation-header lift: the host installs the lifting endpoint filter +/// only when FOUNDRY_HOSTING_ENVIRONMENT is set, then lifts x-agent-user-isolation-key / +/// x-agent-chat-isolation-key into for the request and resets it +/// afterwards. Runs in a non-parallel collection because the gate is a process-wide environment variable. +/// +[Collection("IsolationEnvironment")] +public class IsolationTests +{ + private const string FoundryFlag = "FOUNDRY_HOSTING_ENVIRONMENT"; + + [Fact] + public async Task Headers_IgnoredWithoutFoundryFlagAsync() + { + // Arrange - flag explicitly cleared + using var env = new EnvVarScope(FoundryFlag, null); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new IsolationProbeChannel())); + + // Act + var body = await GetProbeAsync(app, ("alice-uid", "general-cid")); + + // Assert - filter not installed, so Current stays null + Assert.Equal("absent", body); + } + + [Fact] + public async Task BothHeaders_LiftedUnderFoundryFlagAsync() + { + // Arrange + using var env = new EnvVarScope(FoundryFlag, "1"); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new IsolationProbeChannel())); + + // Act + var body = await GetProbeAsync(app, ("alice-uid", "general-cid")); + + // Assert + Assert.Equal("user=alice-uid;chat=general-cid", body); + } + + [Fact] + public async Task OnlyUserHeader_LiftedAsync() + { + // Arrange + using var env = new EnvVarScope(FoundryFlag, "1"); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new IsolationProbeChannel())); + + // Act + var body = await GetProbeAsync(app, ("alice-uid", null)); + + // Assert + Assert.Equal("user=alice-uid;chat=", body); + } + + [Fact] + public async Task OnlyChatHeader_LiftedAsync() + { + // Arrange + using var env = new EnvVarScope(FoundryFlag, "1"); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new IsolationProbeChannel())); + + // Act + var body = await GetProbeAsync(app, (null, "general-cid")); + + // Assert + Assert.Equal("user=;chat=general-cid", body); + } + + [Fact] + public async Task NoHeaders_AbsentUnderFlagAsync() + { + // Arrange + using var env = new EnvVarScope(FoundryFlag, "1"); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new IsolationProbeChannel())); + + // Act + var body = await GetProbeAsync(app, (null, null)); + + // Assert - filter is installed but a no-op without headers + Assert.Equal("absent", body); + } + + [Fact] + public async Task EmptyUserHeader_TreatedAsAbsentAsync() + { + // Arrange + using var env = new EnvVarScope(FoundryFlag, "1"); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new IsolationProbeChannel())); + + // Act - present-but-empty user header must not bind an empty key + var body = await GetProbeAsync(app, ("", "general-cid")); + + // Assert + Assert.Equal("user=;chat=general-cid", body); + } + + [Fact] + public async Task ResetsAcrossRequests_NoLeakAsync() + { + // Arrange + using var env = new EnvVarScope(FoundryFlag, "1"); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new IsolationProbeChannel())); + + // Act - a bound request followed by a header-less request + var first = await GetProbeAsync(app, ("alice-uid", null)); + var second = await GetProbeAsync(app, (null, null)); + + // Assert - the second request does not inherit alice-uid + Assert.Equal("user=alice-uid;chat=", first); + Assert.Equal("absent", second); + } + + [Fact] + public async Task ConcurrentRequests_AreIsolatedAsync() + { + // Arrange + using var env = new EnvVarScope(FoundryFlag, "1"); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new IsolationProbeChannel())); + + // Act - parallel requests with distinct user keys must not bleed + var alice = GetProbeAsync(app, ("alice-uid", null)); + var bob = GetProbeAsync(app, ("bob-uid", null)); + var results = await Task.WhenAll(alice, bob); + + // Assert + Assert.Equal("user=alice-uid;chat=", results[0]); + Assert.Equal("user=bob-uid;chat=", results[1]); + } + + [Fact] + public async Task Accessor_ResolvesFromDIAsync() + { + // Arrange / Act + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new IsolationProbeChannel())); + var accessor = app.Services.GetService(); + + // Assert - the host registers the accessor for downstream providers + Assert.NotNull(accessor); + } + + [Fact] + public async Task Host_StartsAndStopsUnderFoundryFlagAsync() + { + // Arrange / Act - installing the filter must not disturb host lifecycle (non-request scopes) + using var env = new EnvVarScope(FoundryFlag, "1"); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new IsolationProbeChannel())); + + // Act - a plain request still succeeds + var body = await GetProbeAsync(app, (null, null)); + + // Assert + Assert.Equal("absent", body); + } + + private static async Task GetProbeAsync(TestHostApp app, (string? User, string? Chat) headers) + { + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/probe")); + if (headers.User is not null) + { + request.Headers.TryAddWithoutValidation(IsolationKeys.UserHeader, headers.User); + } + + if (headers.Chat is not null) + { + request.Headers.TryAddWithoutValidation(IsolationKeys.ChatHeader, headers.Chat); + } + + var response = await app.Client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await response.Content.ReadAsStringAsync(); + } +} + +/// Disables parallelization for isolation tests that mutate the process-wide Foundry env var. +[CollectionDefinition("IsolationEnvironment", DisableParallelization = true)] +public class IsolationEnvironmentDefinition +{ +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.csproj new file mode 100644 index 00000000000..d9deb08f249 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.csproj @@ -0,0 +1,17 @@ + + + + $(TargetFrameworksCore) + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesContentRenderingParityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesContentRenderingParityTests.cs new file mode 100644 index 00000000000..49e2d5c1c5d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesContentRenderingParityTests.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Python-parity coverage for the Responses channel's content rendering (mirrors test_channel.py): multimodal +/// output items, function-result media projection, call/result coalescing, and raw-item passthrough/replacement. +/// Each case posts to /responses and asserts the rendered output array. +/// +public class ResponsesContentRenderingParityTests +{ + [Fact] + public async Task MultimodalResponse_RendersReasoningCallResultMessageAsync() + { + var contents = new List + { + new TextReasoningContent("checking"), + new FunctionCallContent("call_1", "collect_media", new Dictionary { ["city"] = "Seattle" }), + new FunctionResultContent("call_1", new List + { + new TextContent("caption"), + new UriContent("https://example.com/cat.png", "image/png"), + new HostedFileContent("file_pdf") { MediaType = "application/pdf" }, + }), + new TextContent("done"), + }; + + var output = await PostAsync(new ScriptedAgent([.. contents])); + + Assert.Equal(["reasoning", "function_call", "function_call_output", "message"], TypesOf(output)); + Assert.Equal("checking", output[0].GetProperty("content")[0].GetProperty("text").GetString()); + Assert.Equal("collect_media", output[1].GetProperty("name").GetString()); + + var args = JsonNode.Parse(output[1].GetProperty("arguments").GetString()!)!; + Assert.Equal("Seattle", args["city"]!.GetValue()); + + var parts = output[2].GetProperty("output"); + Assert.Equal("input_text", parts[0].GetProperty("type").GetString()); + Assert.Equal("caption", parts[0].GetProperty("text").GetString()); + Assert.Equal("input_image", parts[1].GetProperty("type").GetString()); + Assert.Equal("https://example.com/cat.png", parts[1].GetProperty("image_url").GetString()); + Assert.Equal("auto", parts[1].GetProperty("detail").GetString()); + Assert.Equal("input_file", parts[2].GetProperty("type").GetString()); + Assert.Equal("file_pdf", parts[2].GetProperty("file_id").GetString()); + + Assert.Equal("done", output[3].GetProperty("content")[0].GetProperty("text").GetString()); + } + + [Fact] + public async Task FunctionResultException_IsPreservedAsStringAsync() + { + var result = new FunctionResultContent("call_1", null) { Exception = new InvalidOperationException("tool failed") }; + var output = await PostAsync(new ScriptedAgent(result)); + + Assert.Equal("function_call_output", output[0].GetProperty("type").GetString()); + Assert.Equal("tool failed", output[0].GetProperty("output").GetString()); + } + + [Fact] + public async Task ImageGenerationAndMcpCallResult_CoalesceWithinMessageAsync() + { + var contents = new List + { + new ImageGenerationToolCallContent("ig_1"), + new ImageGenerationToolResultContent("ig_1") + { + Outputs = [new DataContent("data:image/png;base64,aGVsbG8=", "image/png")], + }, + new McpServerToolCallContent("mcp_1", "lookup", "weather") + { + Arguments = new Dictionary { ["city"] = "Seattle" }, + }, + new McpServerToolResultContent("mcp_1") { Outputs = [new TextContent("sunny")] }, + }; + + var output = await PostAsync(new ScriptedAgent([.. contents])); + + Assert.Equal(["image_generation_call", "mcp_call"], TypesOf(output)); + Assert.Equal("ig_1", output[0].GetProperty("id").GetString()); + Assert.Equal("aGVsbG8=", output[0].GetProperty("result").GetString()); + + Assert.Equal("mcp_1", output[1].GetProperty("id").GetString()); + Assert.Equal("weather", output[1].GetProperty("server_label").GetString()); + Assert.Equal("lookup", output[1].GetProperty("name").GetString()); + Assert.Equal("sunny", output[1].GetProperty("output").GetString()); + var args = JsonNode.Parse(output[1].GetProperty("arguments").GetString()!)!; + Assert.Equal("Seattle", args["city"]!.GetValue()); + } + + [Fact] + public async Task McpCallResult_CoalesceAcrossMessagesAsync() + { + var messages = new List + { + new(ChatRole.Assistant, [new McpServerToolCallContent("mcp_1", "lookup", "weather") + { + Arguments = new Dictionary { ["city"] = "Seattle" }, + }]), + new(ChatRole.Tool, [new McpServerToolResultContent("mcp_1") { Outputs = [new TextContent("sunny")] }]), + }; + + var output = await PostAsync(new ScriptedAgent(messages)); + + Assert.Equal(["mcp_call"], TypesOf(output)); + Assert.Equal("mcp_1", output[0].GetProperty("id").GetString()); + Assert.Equal("sunny", output[0].GetProperty("output").GetString()); + } + + [Fact] + public async Task RawResponsesOutputItem_IsPreservedVerbatimAsync() + { + JsonObject Raw() => new() + { + ["id"] = "ig_1", + ["type"] = "image_generation_call", + ["result"] = "base64-image", + ["status"] = "completed", + }; + + var contents = new List + { + new ImageGenerationToolCallContent("ig_1") { RawRepresentation = Raw() }, + new ImageGenerationToolResultContent("ig_1") { RawRepresentation = Raw() }, + }; + + var output = await PostAsync(new ScriptedAgent([.. contents])); + + Assert.Equal(1, output.GetArrayLength()); + Assert.Equal("image_generation_call", output[0].GetProperty("type").GetString()); + Assert.Equal("ig_1", output[0].GetProperty("id").GetString()); + Assert.Equal("base64-image", output[0].GetProperty("result").GetString()); + Assert.Equal("completed", output[0].GetProperty("status").GetString()); + } + + [Fact] + public async Task LaterRawOutputItem_ReplacesEarlierPartialAsync() + { + JsonObject Partial() => new() + { + ["id"] = "mcp_1", + ["type"] = "mcp_call", + ["server_label"] = "weather", + ["name"] = "lookup", + ["arguments"] = "{}", + ["status"] = "in_progress", + }; + + JsonObject Completed() + { + var c = Partial(); + c["status"] = "completed"; + c["output"] = "sunny"; + return c; + } + + var contents = new List + { + new McpServerToolCallContent("mcp_1", "lookup", "weather") { RawRepresentation = Partial() }, + new McpServerToolResultContent("mcp_1") { RawRepresentation = Completed() }, + }; + + var output = await PostAsync(new ScriptedAgent([.. contents])); + + Assert.Equal(1, output.GetArrayLength()); + Assert.Equal("completed", output[0].GetProperty("status").GetString()); + Assert.Equal("sunny", output[0].GetProperty("output").GetString()); + } + + private static async Task PostAsync(AIAgent agent) + { + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(agent).AddResponsesChannel()); + var response = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json("{ \"input\": \"hi\" }")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + return doc.RootElement.GetProperty("output").Clone(); + } + + private static List TypesOf(JsonElement output) + { + var types = new List(); + foreach (var item in output.EnumerateArray()) + { + types.Add(item.GetProperty("type").GetString()!); + } + + return types; + } + + private static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesParityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesParityTests.cs new file mode 100644 index 00000000000..7ba0190dc23 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesParityTests.cs @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Python-parity coverage for the Responses channel: session anchoring via previous_response_id, +/// generation-option forwarding (stripped by default, kept by a custom run hook), caller identity, and the +/// richer input-parsing surface (content parts, image/file inputs, 422 on malformed shapes). +/// +public class ResponsesParityTests +{ + [Fact] + public async Task PreviousResponseId_ResumesSessionAsync() + { + // Arrange - default channel, session-counting agent + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new CountingAgent()).AddResponsesChannel()); + + // Act - first turn mints a response id that anchors the session + var (firstText, responseId) = await PostAndReadAsync(app, "{ \"input\": \"hi\" }"); + + // Second turn replays that id as previous_response_id -> same isolation key -> same session + var (secondText, _) = await PostAndReadAsync(app, $"{{ \"input\": \"hi\", \"previous_response_id\": \"{responseId}\" }}"); + + // Assert - the count increments, proving the session resumed + Assert.Equal("1", firstText); + Assert.Equal("2", secondText); + } + + [Fact] + public async Task NewRequests_WithoutPreviousId_PartitionAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new CountingAgent()).AddResponsesChannel()); + + // Act - two unrelated first turns each mint their own anchor + var (first, _) = await PostAndReadAsync(app, "{ \"input\": \"hi\" }"); + var (second, _) = await PostAndReadAsync(app, "{ \"input\": \"hi\" }"); + + // Assert - distinct response ids partition into fresh sessions + Assert.Equal("1", first); + Assert.Equal("1", second); + } + + [Fact] + public async Task DefaultChannel_StripsGenerationOptionsAsync() + { + // Arrange - no run hook => default strip + var agent = new RecordingAgent(); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(agent).AddResponsesChannel()); + + // Act + await PostAsync(app, "{ \"input\": \"hi\", \"max_output_tokens\": 7, \"temperature\": 0.5 }"); + + // Assert - parsed options were stripped before reaching the agent + Assert.True(agent.RunCalled); + Assert.Null(agent.LastChatOptions); + } + + [Fact] + public async Task CustomRunHook_ForwardsGenerationOptionsAsync() + { + // Arrange - a run hook replaces the default strip and keeps options + var agent = new RecordingAgent(); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(agent).AddResponsesChannel(o => o.RunHook = new CapturingRunHook())); + + // Act + await PostAsync(app, "{ \"input\": \"hi\", \"max_output_tokens\": 7, \"parallel_tool_calls\": true }"); + + // Assert - remapped generation options reached the agent + Assert.NotNull(agent.LastChatOptions); + Assert.Equal(7, agent.LastChatOptions!.MaxOutputTokens); + Assert.True(agent.LastChatOptions.AllowMultipleToolCalls); + } + + [Fact] + public async Task SafetyIdentifier_SurfacedAsIdentityAsync() + { + // Arrange + var hook = new CapturingRunHook(); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel(o => o.RunHook = hook)); + + // Act + await PostAsync(app, "{ \"input\": \"hi\", \"safety_identifier\": \"u-123\" }"); + + // Assert + Assert.NotNull(hook.Last); + Assert.NotNull(hook.Last!.Identity); + Assert.Equal("responses", hook.Last.Identity!.Channel); + Assert.Equal("u-123", hook.Last.Identity.NativeId); + } + + [Fact] + public async Task User_FallbackSurfacedAsIdentityAsync() + { + // Arrange + var hook = new CapturingRunHook(); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel(o => o.RunHook = hook)); + + // Act - legacy `user` field falls back when safety_identifier is absent + await PostAsync(app, "{ \"input\": \"hi\", \"user\": \"legacy-uid\" }"); + + // Assert + Assert.Equal("legacy-uid", hook.Last!.Identity!.NativeId); + } + + [Fact] + public async Task MessageEnvelope_WithInputTextContent_IsParsedAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel()); + + // Act - message envelope carrying an input_text content part + var body = await PostReadBodyAsync(app, "{ \"input\": [ { \"type\": \"message\", \"role\": \"user\", \"content\": [ { \"type\": \"input_text\", \"text\": \"hello\" } ] } ] }"); + + // Assert + Assert.Contains("hello", body); + } + + [Fact] + public async Task LooseContentPart_IsParsedAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel()); + + // Act - a bare content part (no message envelope) buffers into a user message + var body = await PostReadBodyAsync(app, "{ \"input\": [ { \"type\": \"input_text\", \"text\": \"loose\" } ] }"); + + // Assert + Assert.Contains("loose", body); + } + + [Fact] + public async Task InputImage_IsAcceptedAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel()); + + // Act + var response = await PostAsync(app, "{ \"input\": [ { \"type\": \"input_image\", \"image_url\": \"https://example.com/a.png\" } ] }"); + + // Assert - image input parses (not a 422) + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task UnsupportedContentType_Returns422Async() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel()); + + // Act + var response = await PostAsync(app, "{ \"input\": [ { \"type\": \"input_audio\", \"audio\": \"x\" } ] }"); + + // Assert + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + } + + [Fact] + public async Task NonObjectArrayItem_Returns422Async() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel()); + + // Act - a bare string array item is not a valid input object + var response = await PostAsync(app, "{ \"input\": [ \"bare\" ] }"); + + // Assert + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + } + + [Fact] + public async Task InputFile_WithoutUrlOrId_Returns422Async() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel()); + + // Act + var response = await PostAsync(app, "{ \"input\": [ { \"type\": \"input_file\" } ] }"); + + // Assert + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + } + + private static StringContent Json(string body) => new(body, Encoding.UTF8, "application/json"); + + private static Task PostAsync(TestHostApp app, string body) => + app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json(body)); + + private static async Task PostReadBodyAsync(TestHostApp app, string body) + { + var response = await PostAsync(app, body); + return await response.Content.ReadAsStringAsync(); + } + + private static async Task<(string Text, string ResponseId)> PostAndReadAsync(TestHostApp app, string body) + { + var raw = await PostReadBodyAsync(app, body); + using var doc = JsonDocument.Parse(raw); + var text = doc.RootElement.GetProperty("output")[0].GetProperty("content")[0].GetProperty("text").GetString()!; + var id = doc.RootElement.GetProperty("id").GetString()!; + return (text, id); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesParsingParityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesParsingParityTests.cs new file mode 100644 index 00000000000..39189b82742 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesParsingParityTests.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Parser-parity coverage: asserts the parsed content (not just HTTP status) by +/// capturing the the channel built, mirroring Python's test_parsing.py. +/// +public class ResponsesParsingParityTests +{ + private static async Task CaptureAsync(string body) + { + var hook = new CapturingRunHook(); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel(o => o.RunHook = hook)); + var response = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json(body)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(hook.Last); + return hook.Last!; + } + + private static IReadOnlyList Messages(ChannelRequest request) => Assert.IsAssignableFrom>(request.Input); + + [Fact] + public async Task StringInput_BecomesSingleUserTextMessageAsync() + { + var request = await CaptureAsync("{ \"input\": \"hi\" }"); + var messages = Messages(request); + Assert.Single(messages); + Assert.Equal(ChatRole.User, messages[0].Role); + Assert.Equal("hi", Assert.IsType(Assert.Single(messages[0].Contents)).Text); + } + + [Fact] + public async Task MessageEnvelope_MapsRoleAndTextAsync() + { + var request = await CaptureAsync("{ \"input\": [ { \"type\": \"message\", \"role\": \"assistant\", \"content\": \"prior\" } ] }"); + var messages = Messages(request); + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + Assert.Equal("prior", Assert.IsType(Assert.Single(messages[0].Contents)).Text); + } + + [Fact] + public async Task LooseText_FlushesAsUserMessageBeforeEnvelopeAsync() + { + var request = await CaptureAsync("{ \"input\": [ { \"type\": \"input_text\", \"text\": \"loose\" }, { \"type\": \"message\", \"role\": \"assistant\", \"content\": \"prior\" } ] }"); + var messages = Messages(request); + Assert.Equal(2, messages.Count); + Assert.Equal(ChatRole.User, messages[0].Role); + Assert.Equal("loose", Assert.IsType(Assert.Single(messages[0].Contents)).Text); + Assert.Equal(ChatRole.Assistant, messages[1].Role); + } + + [Fact] + public async Task InputImage_StringUrl_BecomesUriContentAsync() + { + var request = await CaptureAsync("{ \"input\": [ { \"type\": \"input_image\", \"image_url\": \"https://example.com/a.png\" } ] }"); + var content = Assert.Single(Messages(request)[0].Contents); + Assert.Equal("https://example.com/a.png", Assert.IsType(content).Uri.ToString()); + } + + [Fact] + public async Task InputImage_ObjectUrl_BecomesUriContentAsync() + { + var request = await CaptureAsync("{ \"input\": [ { \"type\": \"input_image\", \"image_url\": { \"url\": \"https://example.com/b.png\" } } ] }"); + var content = Assert.Single(Messages(request)[0].Contents); + Assert.Equal("https://example.com/b.png", Assert.IsType(content).Uri.ToString()); + } + + [Fact] + public async Task InputFile_Url_BecomesUriContentAsync() + { + var request = await CaptureAsync("{ \"input\": [ { \"type\": \"input_file\", \"file_url\": \"https://example.com/d.pdf\", \"mime_type\": \"application/pdf\" } ] }"); + var content = Assert.Single(Messages(request)[0].Contents); + var uri = Assert.IsType(content); + Assert.Equal("https://example.com/d.pdf", uri.Uri.ToString()); + Assert.Equal("application/pdf", uri.MediaType); + } + + [Fact] + public async Task InputFile_FileId_BecomesHostedFileContentAsync() + { + var request = await CaptureAsync("{ \"input\": [ { \"type\": \"input_file\", \"file_id\": \"file-123\" } ] }"); + var content = Assert.Single(Messages(request)[0].Contents); + Assert.Equal("file-123", Assert.IsType(content).FileId); + } + + [Fact] + public async Task Identity_AbsentWhenNoIdentifierAsync() + { + var request = await CaptureAsync("{ \"input\": \"hi\" }"); + Assert.Null(request.Identity); + } + + [Fact] + public async Task Identity_SafetyIdentifierPreferredOverUserAsync() + { + var request = await CaptureAsync("{ \"input\": \"hi\", \"safety_identifier\": \"abc\", \"user\": \"legacy\" }"); + Assert.NotNull(request.Identity); + Assert.Equal("abc", request.Identity!.NativeId); + } + + [Fact] + public async Task Identity_NonStringSafetyIdentifierIsIgnoredAsync() + { + // Python parse_responses_identity returns None for a non-string identifier rather than rejecting the + // request; the channel must tolerate it and surface no identity (HTTP 200, not 400). + var request = await CaptureAsync("{ \"input\": \"hi\", \"safety_identifier\": 42 }"); + Assert.Null(request.Identity); + } + + [Theory] + [InlineData("{ \"input\": 123 }")] + [InlineData("{ \"input\": [] }")] + [InlineData("{ \"input\": [ { \"type\": \"message\", \"role\": \"user\", \"content\": 123 } ] }")] + [InlineData("{ \"input\": [ { \"type\": \"message\", \"role\": \"user\", \"content\": [ 123 ] } ] }")] + [InlineData("{ \"input\": [ { \"type\": \"input_image\" } ] }")] + public async Task MalformedInput_Returns422Async(string body) + { + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel()); + var response = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json(body)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + } + + private static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesProtocolTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesProtocolTests.cs new file mode 100644 index 00000000000..60d004fde4b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesProtocolTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// Validates the Responses channel's own wire mapping: JSON envelope, SSE frames, input parsing, errors. +public class ResponsesProtocolTests +{ + [Fact] + public async Task Sync_StringInput_RendersResponsesJsonAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ \"input\": \"hi\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var doc = JsonDocument.Parse(body); + Assert.Equal("completed", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal(FakeChatAgent.Reply, doc.RootElement.GetProperty("output")[0].GetProperty("content")[0].GetProperty("text").GetString()); + } + + [Fact] + public async Task Sync_InputItemArray_IsParsedAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel()); + + // Act - Responses input-item array shape (message envelope) + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), + Json("{ \"input\": [ { \"type\": \"message\", \"role\": \"user\", \"content\": \"piece\" } ] }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - echo agent reflected the parsed user text + Assert.Contains("piece", body); + } + + [Fact] + public async Task Streaming_EmitsCreatedDeltaCompletedAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ \"input\": \"hi\", \"stream\": true }")); + var body = await response.Content.ReadAsStringAsync(); + var frames = Sse.Parse(body); + var events = System.Linq.Enumerable.ToList(frames.Events()); + + // Assert - frame sequence and reassembled text + Assert.Equal("text/event-stream", response.Content.Headers.ContentType!.MediaType); + Assert.Equal("response.created", events[0]); + Assert.Contains("response.output_text.delta", events); + Assert.Equal("response.completed", events[^1]); + Assert.Contains(FakeChatAgent.Reply, body); + } + + [Fact] + public async Task MissingInput_Returns422Async() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ }")); + + // Assert - missing input is an unprocessable entity (Python parity), not a transport error + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + } + + [Fact] + public async Task MalformedJson_Returns400Async() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ not json")); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task WorkflowTarget_RendersCompletedResponseAsync() + { + // Arrange - workflow target with a run hook adapting the parsed input to the workflow's string input + await using var app = await TestHostApp.StartAsync(b => b + .AddAgentFrameworkHost(WorkflowFactory.Echo()) + .AddResponsesChannel(o => o.RunHook = new RewriteInputRunHook("ping"))); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ \"input\": \"hi\" }")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private static StringContent Json(string json) => new(json, System.Text.Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesRenderingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesRenderingTests.cs new file mode 100644 index 00000000000..8a5c61e6390 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesRenderingTests.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Python-parity coverage for the Responses channel's output rendering: mixed content renders as typed output +/// items (reasoning / function_call / function_call_output / message), and streaming emits the richer SSE +/// event sequence (output_item.added, content_part.added, output_text.delta/done, content_part.done, +/// output_item.done) rather than only created/delta/completed. +/// +public class ResponsesRenderingTests +{ + [Fact] + public async Task MixedContent_RendersTypedOutputItemsAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new RichContentAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json("{ \"input\": \"hi\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - typed items in order, each with the expected payload + using var doc = JsonDocument.Parse(body); + var output = doc.RootElement.GetProperty("output"); + var types = new List(); + string? reasoningText = null, functionOutput = null, messageText = null, callName = null; + foreach (var item in output.EnumerateArray()) + { + var type = item.GetProperty("type").GetString()!; + types.Add(type); + switch (type) + { + case "reasoning": reasoningText = item.GetProperty("content")[0].GetProperty("text").GetString(); break; + case "function_call": callName = item.GetProperty("name").GetString(); break; + case "function_call_output": functionOutput = item.GetProperty("output").GetString(); break; + case "message": messageText = item.GetProperty("content")[0].GetProperty("text").GetString(); break; + } + } + + Assert.Equal("reasoning,function_call,function_call_output,message", string.Join(",", types)); + Assert.Equal(RichContentAgent.ReasoningText, reasoningText); + Assert.Equal(RichContentAgent.ToolName, callName); + Assert.Equal("sunny in Seattle", functionOutput); + Assert.Equal(RichContentAgent.FinalText, messageText); + } + + [Fact] + public async Task Envelope_HasExpectedShapeAsync() + { + // Arrange - no model in the request, so the envelope falls back to "agent" + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json("{ \"input\": \"hi\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + Assert.Equal("response", root.GetProperty("object").GetString()); + Assert.StartsWith("resp_", root.GetProperty("id").GetString()); + Assert.True(root.GetProperty("created_at").GetInt64() > 0); + Assert.Equal("completed", root.GetProperty("status").GetString()); + Assert.Equal("agent", root.GetProperty("model").GetString()); + } + + [Fact] + public async Task FunctionCall_CarriesCallIdNameAndArgumentsAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new RichContentAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json("{ \"input\": \"hi\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert + using var doc = JsonDocument.Parse(body); + JsonElement? call = null; + foreach (var item in doc.RootElement.GetProperty("output").EnumerateArray()) + { + if (item.GetProperty("type").GetString() == "function_call") + { + call = item; + break; + } + } + + Assert.NotNull(call); + Assert.Equal(RichContentAgent.CallId, call!.Value.GetProperty("call_id").GetString()); + Assert.Equal(RichContentAgent.ToolName, call.Value.GetProperty("name").GetString()); + Assert.Contains("Seattle", call.Value.GetProperty("arguments").GetString()); + } + + [Fact] + public async Task Streaming_EmitsRichEventSequenceAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json("{ \"input\": \"hi\", \"stream\": true }")); + var body = await response.Content.ReadAsStringAsync(); + var events = System.Linq.Enumerable.ToList(Sse.Parse(body).Events()); + + // Assert - the richer Responses event shape is present and well-ordered + int Idx(string name) => events.IndexOf(name); + Assert.Equal("response.created", events[0]); + Assert.Equal("response.completed", events[^1]); + Assert.True(Idx("response.output_item.added") < Idx("response.content_part.added")); + Assert.True(Idx("response.content_part.added") < Idx("response.output_text.delta")); + Assert.True(Idx("response.output_text.delta") < Idx("response.output_text.done")); + Assert.True(Idx("response.output_text.done") < Idx("response.content_part.done")); + Assert.True(Idx("response.content_part.done") < Idx("response.output_item.done")); + Assert.True(Idx("response.output_item.done") < Idx("response.completed")); + } + + [Fact] + public async Task EmptyPath_MountsAtAppRootAsync() + { + // Arrange - Path "" mounts the Responses route at the app root + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel(o => o.Path = "")); + + // Act + var atRoot = await app.Client.PostAsync(new Uri("http://localhost/"), Json("{ \"input\": \"hi\" }")); + var atResponses = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json("{ \"input\": \"hi\" }")); + + // Assert + Assert.Equal(System.Net.HttpStatusCode.OK, atRoot.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.NotFound, atResponses.StatusCode); + } + + [Fact] + public async Task CustomPath_MountsRouteAndDefaultPath404sAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel(o => o.Path = "/v1/responses")); + + // Act + var atCustom = await app.Client.PostAsync(new Uri("http://localhost/v1/responses"), Json("{ \"input\": \"hi\" }")); + var atDefault = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json("{ \"input\": \"hi\" }")); + + // Assert + Assert.Equal(System.Net.HttpStatusCode.OK, atCustom.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.NotFound, atDefault.StatusCode); + } + + private static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesSessionAnchorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesSessionAnchorTests.cs new file mode 100644 index 00000000000..c13dfc7c767 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesSessionAnchorTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Session-anchor precedence parity (Python test_channel.py): previous_response_id wins, else a +/// Foundry-lifted chat isolation key, else the freshly minted response_id; the chat header is ignored +/// outside the Foundry environment. Runs in the non-parallel isolation collection (process-wide env var). +/// +[Collection("IsolationEnvironment")] +public class ResponsesSessionAnchorTests +{ + private const string FoundryFlag = "FOUNDRY_HOSTING_ENVIRONMENT"; + + [Fact] + public async Task ChatHeader_IgnoredWithoutFlag_MintedIdAnchorsAsync() + { + // Arrange - no Foundry flag, so the chat header is not lifted + using var env = new EnvVarScope(FoundryFlag, null); + var hook = new CapturingRunHook(); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel(o => o.RunHook = hook)); + + // Act + await PostAsync(app, "{ \"input\": \"hi\" }", chatKey: "chat-abc"); + + // Assert - the minted response id anchors the session, not the (ignored) header + Assert.StartsWith("resp_", hook.Last!.Session!.IsolationKey); + } + + [Fact] + public async Task ChatHeader_AnchorsSessionUnderFlagAsync() + { + // Arrange + using var env = new EnvVarScope(FoundryFlag, "1"); + var hook = new CapturingRunHook(); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel(o => o.RunHook = hook)); + + // Act - no previous_response_id, so the lifted chat key anchors the session + await PostAsync(app, "{ \"input\": \"hi\" }", chatKey: "chat-abc"); + + // Assert + Assert.Equal("chat-abc", hook.Last!.Session!.IsolationKey); + } + + [Fact] + public async Task PreviousResponseId_WinsOverChatHeaderAsync() + { + // Arrange + using var env = new EnvVarScope(FoundryFlag, "1"); + var hook = new CapturingRunHook(); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel(o => o.RunHook = hook)); + + // Act - both present; the explicit protocol anchor wins + await PostAsync(app, "{ \"input\": \"hi\", \"previous_response_id\": \"resp_prev\" }", chatKey: "chat-abc"); + + // Assert + Assert.Equal("resp_prev", hook.Last!.Session!.IsolationKey); + } + + private static async Task PostAsync(TestHostApp app, string body, string chatKey) + { + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri("http://localhost/responses")) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + request.Headers.TryAddWithoutValidation("x-agent-chat-isolation-key", chatKey); + var response = await app.Client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesStreamingFinalizeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesStreamingFinalizeTests.cs new file mode 100644 index 00000000000..e3b5f93604b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesStreamingFinalizeTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Python-parity coverage for streaming finalization: the response hook is applied to the final streamed +/// result, and a mid-stream error is surfaced as a Responses response.failed SSE event rather than an +/// (invalid) post-headers JSON error. +/// +public class ResponsesStreamingFinalizeTests +{ + [Fact] + public async Task MidStreamError_EmitsResponseFailedAsync() + { + // Arrange - an agent that yields one chunk then throws + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new ThrowingStreamAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json("{ \"input\": \"hi\", \"stream\": true }")); + var body = await response.Content.ReadAsStringAsync(); + var events = System.Linq.Enumerable.ToList(Sse.Parse(body).Events()); + + // Assert - the stream started (200 + created) and terminated with response.failed, not a JSON 500 + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/event-stream", response.Content.Headers.ContentType!.MediaType); + Assert.Equal("response.created", events[0]); + Assert.Contains("response.failed", events); + Assert.Equal("response.failed", events[^1]); + Assert.Contains(ThrowingStreamAgent.ErrorMessage, body); + } + + [Fact] + public async Task StreamingAppliesResponseHookOnFinalAsync() + { + // Arrange + var hook = new RecordingResponseHook(); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel(o => o.ResponseHook = hook)); + + // Act + var response = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json("{ \"input\": \"hi\", \"stream\": true }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - the response hook ran for the streamed request and the final completed event carries the text + Assert.True(hook.Invoked); + Assert.Contains(FakeChatAgent.Reply, body); + Assert.EndsWith("\n\n", body); + } + + private static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesStreamingParityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesStreamingParityTests.cs new file mode 100644 index 00000000000..8d2c89fead8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesStreamingParityTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Python-parity coverage for rich streaming (mirrors +/// test_sse_completed_preserves_streamed_multimodal_updates_when_finalize_fails): streamed reasoning and +/// function-call content emit their delta/done SSE events and dedicated output items, and the terminal +/// completed envelope carries the full multimodal output. +/// +public class ResponsesStreamingParityTests +{ + [Fact] + public async Task MultimodalStream_EmitsRichEventsAndCompletedOutputAsync() + { + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new MultimodalStreamAgent()).AddResponsesChannel()); + + var response = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json("{ \"input\": \"hi\", \"stream\": true }")); + var body = await response.Content.ReadAsStringAsync(); + var events = Sse.Parse(body).Events().ToList(); + + // The rich per-content event types are present. + Assert.Contains("response.output_item.added", events); + Assert.Contains("response.output_item.done", events); + Assert.Contains("response.content_part.added", events); + Assert.Contains("response.output_text.done", events); + Assert.Contains("response.reasoning_text.delta", events); + Assert.Contains("response.reasoning_text.done", events); + Assert.Contains("response.function_call_arguments.delta", events); + Assert.Contains("response.function_call_arguments.done", events); + + // The added output items, in order, cover message / reasoning / function_call / function_call_output. + var addedTypes = new List(); + JsonElement? functionCallAdded = null, functionOutputAdded = null; + foreach (var (type, data) in Sse.Parse(body)) + { + if (type != "response.output_item.added") + { + continue; + } + + using var doc = JsonDocument.Parse(data); + var item = doc.RootElement.GetProperty("item"); + var itemType = item.GetProperty("type").GetString()!; + addedTypes.Add(itemType); + if (itemType == "function_call") + { + functionCallAdded = item.Clone(); + } + else if (itemType == "function_call_output") + { + functionOutputAdded = item.Clone(); + } + } + + Assert.Equal(["message", "reasoning", "function_call", "function_call_output"], addedTypes); + Assert.Equal(MultimodalStreamAgent.ToolName, functionCallAdded!.Value.GetProperty("name").GetString()); + Assert.Equal(string.Empty, functionCallAdded.Value.GetProperty("arguments").GetString()); + var addedParts = functionOutputAdded!.Value.GetProperty("output"); + Assert.Equal("input_image", addedParts[0].GetProperty("type").GetString()); + Assert.Equal(MultimodalStreamAgent.ImageUrl, addedParts[0].GetProperty("image_url").GetString()); + + // The terminal completed envelope carries the full multimodal output. + var completed = LastResponsePayload(body, "response.completed"); + var output = completed.GetProperty("output"); + Assert.Equal(MultimodalStreamAgent.Caption, output[0].GetProperty("content")[0].GetProperty("text").GetString()); + Assert.Equal(MultimodalStreamAgent.Reasoning, output[1].GetProperty("content")[0].GetProperty("text").GetString()); + Assert.Equal(MultimodalStreamAgent.ToolName, output[2].GetProperty("name").GetString()); + Assert.Equal("input_image", output[3].GetProperty("output")[0].GetProperty("type").GetString()); + } + + private static JsonElement LastResponsePayload(string body, string eventType) + { + JsonElement result = default; + foreach (var (type, data) in Sse.Parse(body)) + { + if (type == eventType) + { + using var doc = JsonDocument.Parse(data); + result = doc.RootElement.GetProperty("response").Clone(); + } + } + + return result; + } + + private static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesTracingParityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesTracingParityTests.cs new file mode 100644 index 00000000000..aed2a811aad --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesTracingParityTests.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Python-parity coverage for tracing (mirrors test_sse_streaming_uses_request_parent_span_context): the agent +/// run that backs a deferred SSE stream executes under the request's parent span, so the trace context is not +/// lost across the streaming boundary. +/// +public class ResponsesTracingParityTests +{ + [Fact] + public async Task SseStreaming_RunsUnderRequestParentSpanAsync() + { + using var source = new ActivitySource("test.hosting.channels"); + using var listener = new ActivityListener + { + ShouldListenTo = s => s.Name == source.Name, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + + var agent = new SpanCapturingAgent(); + ActivityTraceId requestTraceId = default; + + await using var app = await TestHostApp.StartAsync( + b => b.AddAgentFrameworkHost(agent).AddResponsesChannel(), + configureApp: app => app.Use(async (context, next) => + { + using var activity = source.StartActivity("request"); + requestTraceId = Activity.Current!.TraceId; + await next(); + })); + + var response = await app.Client.PostAsync(new Uri("http://localhost/responses"), Json("{ \"input\": \"hi\", \"stream\": true }")); + await response.Content.ReadAsStringAsync(); + + Assert.NotEqual(default, requestTraceId); + Assert.Equal(requestTraceId, agent.ObservedTraceId); + } + + private static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/SessionContinuityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/SessionContinuityTests.cs new file mode 100644 index 00000000000..5b3f9feeba1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/SessionContinuityTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Verifies the ADR-0027 session-continuity gate end to end: identical isolation keys resolve to the same +/// cached , different keys partition, and +/// rotates the alias. The isolation key is stamped by trusted middleware (a run hook reading a header), not +/// from the request body. +/// +public class SessionContinuityTests +{ + private const string IsoHeader = "x-iso"; + + [Fact] + public async Task SameIsolationKey_ReusesSessionAsync() + { + // Arrange - counting agent returns the running turn count for its session + await using var app = await StartAsync(); + + // Act - two requests with the same isolation header + var first = await PostAsync(app, "alice"); + var second = await PostAsync(app, "alice"); + + // Assert - same cached session -> count increments + Assert.Equal("1", first); + Assert.Equal("2", second); + } + + [Fact] + public async Task DifferentIsolationKey_GetsFreshSessionAsync() + { + // Arrange + await using var app = await StartAsync(); + + // Act + var alice = await PostAsync(app, "alice"); + var bob = await PostAsync(app, "bob"); + + // Assert - different keys partition into different sessions + Assert.Equal("1", alice); + Assert.Equal("1", bob); + } + + [Fact] + public async Task ResetSession_RotatesAliasToFreshSessionAsync() + { + // Arrange + await using var app = await StartAsync(); + var host = app.Services.GetRequiredService(); + + // Act + var first = await PostAsync(app, "alice"); + var second = await PostAsync(app, "alice"); + await host.ResetSessionAsync("alice"); + var afterReset = await PostAsync(app, "alice"); + + // Assert + Assert.Equal("1", first); + Assert.Equal("2", second); + Assert.Equal("1", afterReset); + } + + private static Task StartAsync() => TestHostApp.StartAsync( + b => b + .AddAgentFrameworkHost(new CountingAgent()) + .AddResponsesChannel(o => o.RunHook = new HeaderIsolationRunHook( + b.Services.BuildServiceProvider().GetRequiredService(), + IsoHeader)), + services => services.AddHttpContextAccessor()); + + private static async Task PostAsync(TestHostApp app, string isolationKey) + { + using var request = new HttpRequestMessage(HttpMethod.Post, new System.Uri("http://localhost/responses")) + { + Content = new StringContent("{ \"input\": \"hi\" }", System.Text.Encoding.UTF8, "application/json"), + }; + request.Headers.Add(IsoHeader, isolationKey); + var response = await app.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + return doc.RootElement.GetProperty("output")[0].GetProperty("content")[0].GetProperty("text").GetString()!; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CapturingRunHook.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CapturingRunHook.cs new file mode 100644 index 00000000000..37f35b3f2d9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CapturingRunHook.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Run hook that records the it sees and returns it unchanged. Because supplying +/// a run hook replaces the channel's default option-stripping behavior, parsed options survive to the agent, +/// and the captured request exposes the parsed / options for assertions. +/// +internal sealed class CapturingRunHook : IChannelRunHook +{ + public ChannelRequest? Last { get; private set; } + + public ValueTask OnRequestAsync(ChannelRequest request, ChannelRunHookContext context, CancellationToken cancellationToken) + { + this.Last = request; + return new ValueTask(request); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ChatMessageListRunHook.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ChatMessageListRunHook.cs new file mode 100644 index 00000000000..cb0a6661fe2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ChatMessageListRunHook.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Run hook that materializes the Responses channel's parsed input (an ) +/// into the concrete List<ChatMessage> that a sequential agent workflow expects as its input. +/// +internal sealed class ChatMessageListRunHook : IChannelRunHook +{ + public ValueTask OnRequestAsync(ChannelRequest request, ChannelRunHookContext context, CancellationToken cancellationToken) + { + if (request.Input is IEnumerable messages) + { + return new(new ChannelRequest(request) { Input = messages.ToList() }); + } + + return new(request); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CountingAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CountingAgent.cs new file mode 100644 index 00000000000..c711141f0fa --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CountingAgent.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Agent whose reply is the running turn count held on its . Because the host +/// runner caches one session instance per active alias, identical isolation keys reuse the same session and +/// the count increments; a fresh session resets to 1. Proves session continuity end to end. +/// +internal sealed class CountingAgent : AIAgent +{ + protected override string? IdCore => "counting-agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new CountingSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new CountingSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var count = session is CountingSession counting ? counting.Next() : 0; + var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, count.ToString(CultureInfo.InvariantCulture))); + return Task.FromResult(response); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var count = session is CountingSession counting ? counting.Next() : 0; + yield return new AgentResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent(count.ToString(CultureInfo.InvariantCulture))] }; + await Task.Yield(); + } + + private sealed class CountingSession : AgentSession + { + private int _count; + + public CountingSession() + { + } + + public CountingSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + + public int Next() => ++this._count; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EchoAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EchoAgent.cs new file mode 100644 index 00000000000..8677da63167 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EchoAgent.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// Agent that replies with the concatenated text of the user messages it received. +internal sealed class EchoAgent : AIAgent +{ + protected override string? IdCore => "echo-agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new EchoSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new EchoSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var text = string.Concat(messages.Where(m => m.Role == ChatRole.User).Select(m => m.Text)); + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, text))); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var text = string.Concat(messages.Where(m => m.Role == ChatRole.User).Select(m => m.Text)); + yield return new AgentResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent(text)] }; + await Task.Yield(); + } + + private sealed class EchoSession : AgentSession + { + public EchoSession() + { + } + + public EchoSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EnvVarScope.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EnvVarScope.cs new file mode 100644 index 00000000000..25c177ac7af --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EnvVarScope.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Sets an environment variable for the lifetime of the scope and restores its previous value on dispose. +/// Used to drive the Foundry hosting-environment gate deterministically inside a non-parallel collection. +/// +internal sealed class EnvVarScope : IDisposable +{ + private readonly string _name; + private readonly string? _previous; + + public EnvVarScope(string name, string? value) + { + this._name = name; + this._previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => Environment.SetEnvironmentVariable(this._name, this._previous); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeChatAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeChatAgent.cs new file mode 100644 index 00000000000..fd2a929ea4f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeChatAgent.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// Deterministic agent that streams a fixed reply in chunks. No live model. +internal sealed class FakeChatAgent : AIAgent +{ + public const string Reply = "Hello from fake agent!"; + + protected override string? IdCore => "fake-agent"; + + public override string? Description => "A fake agent for testing"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new FakeSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new FakeSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (var update in this.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToAgentResponse(); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var messageId = Guid.NewGuid().ToString("N"); + foreach (var chunk in new[] { "Hello", " ", "from", " ", "fake", " ", "agent", "!" }) + { + yield return new AgentResponseUpdate { MessageId = messageId, Role = ChatRole.Assistant, Contents = [new TextContent(chunk)] }; + await Task.Yield(); + } + } + + private sealed class FakeSession : AgentSession + { + public FakeSession() + { + } + + public FakeSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeFunctionCallingChatClient.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeFunctionCallingChatClient.cs new file mode 100644 index 00000000000..5d532b1a271 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeFunctionCallingChatClient.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Two-turn fake that drives a function-call loop deterministically: +/// turn 1 (no function results yet) emits a for the first advertised +/// tool; turn 2 (function results present) emits the final text answer. Combined with a +/// built with tools (which inserts ), +/// this exercises end-to-end tool execution with no live model. +/// +internal sealed class FakeFunctionCallingChatClient : IChatClient +{ + public const string FinalAnswer = "The weather in Seattle is sunny."; + + private readonly IReadOnlyDictionary _arguments; + + /// Initializes the fake. are sent on the emitted function call. + public FakeFunctionCallingChatClient(IReadOnlyDictionary? arguments = null) + { + this._arguments = arguments ?? new Dictionary(); + } + + public ChatClientMetadata Metadata => new("fake-function-calling"); + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + => await this.GetStreamingResponseAsync(messages, options, cancellationToken).ToChatResponseAsync(cancellationToken).ConfigureAwait(false); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var messageList = messages.ToList(); + var hasFunctionResults = messageList.Any(m => m.Contents.Any(c => c is FunctionResultContent)); + var messageId = Guid.NewGuid().ToString("N"); + + if (hasFunctionResults) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, FinalAnswer) { MessageId = messageId }; + yield break; + } + + var tool = (options?.Tools ?? []).OfType().FirstOrDefault(); + if (tool is null) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, "No tools available.") { MessageId = messageId }; + yield break; + } + + var callId = Guid.NewGuid().ToString("N"); + yield return new ChatResponseUpdate + { + MessageId = messageId, + Role = ChatRole.Assistant, + Contents = [new FunctionCallContent(callId, tool.Name, new Dictionary(this._arguments))], + }; + + await Task.Yield(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/IsolationProbeChannel.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/IsolationProbeChannel.cs new file mode 100644 index 00000000000..11f1023bfed --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/IsolationProbeChannel.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Minimal channel whose single GET /probe route captures INSIDE +/// the request and echoes it, so tests can exercise the full filter -> AsyncLocal -> handler hop end to end. +/// Mounted at the app root ( = ). +/// +internal sealed class IsolationProbeChannel : Channel +{ + public override string Name => "isolation-probe"; + + public override string Path => string.Empty; + + public override ChannelContribution Contribute(ChannelContext context) => new() + { + Routes = + [ + endpoints => endpoints.MapGet("/probe", () => + { + var keys = IsolationKeys.Current; + return Results.Text(keys is null + ? "absent" + : $"user={keys.UserKey ?? string.Empty};chat={keys.ChatKey ?? string.Empty}"); + }), + ], + }; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/MultimodalStreamAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/MultimodalStreamAgent.cs new file mode 100644 index 00000000000..1e2d80da8f5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/MultimodalStreamAgent.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Streams a single update carrying mixed content (text, reasoning, a function call, and an image URI) so the +/// channel's rich streaming SSE rendering can be exercised. No live model. +/// +internal sealed class MultimodalStreamAgent : AIAgent +{ + public const string Caption = "caption"; + public const string Reasoning = "thinking"; + public const string ToolName = "lookup"; + public const string ImageUrl = "https://example.com/cat.png"; + + protected override string? IdCore => "multimodal-stream-agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new MultimodalSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new MultimodalSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (var update in this.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToAgentResponse(); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var contents = new List + { + new TextContent(Caption), + new TextReasoningContent(Reasoning), + new FunctionCallContent("call_1", ToolName, new Dictionary { ["city"] = "Seattle" }), + new UriContent(ImageUrl, "image/png"), + }; + yield return new AgentResponseUpdate { Role = ChatRole.Assistant, Contents = contents }; + await Task.Yield(); + } + + private sealed class MultimodalSession : AgentSession + { + public MultimodalSession() + { + } + + public MultimodalSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs new file mode 100644 index 00000000000..cd1dcda1a75 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Minimal test-only channel used to exercise the channel/host contract directly, independent of any +/// concrete protocol. Contributes a couple of routes that call the host run/stream seam, an endpoint +/// filter that stamps a header, and lifecycle callbacks that flip flags. +/// +internal sealed class ProbeChannel : Channel +{ + private readonly string _path; + + public ProbeChannel(string path = "/probe") + { + this._path = path; + } + + public override string Name => "probe"; + + public override string Path => this._path; + + public bool StartupFired { get; private set; } + + public bool ShutdownFired { get; private set; } + + public override ChannelContribution Contribute(ChannelContext context) => new() + { + EndpointFilters = [new HeaderStampFilter()], + OnStartup = _ => { this.StartupFired = true; return default; }, + OnShutdown = _ => { this.ShutdownFired = true; return default; }, + Routes = + [ + endpoints => + { + endpoints.MapGet("/ping", () => Results.Text("pong")); + + endpoints.MapPost("/run", async (HttpContext http) => + { + var input = await new System.IO.StreamReader(http.Request.Body).ReadToEndAsync(http.RequestAborted).ConfigureAwait(false); + var request = new ChannelRequest("probe", "message.create", input); + var result = await context.RunAsync(request, http.RequestAborted).ConfigureAwait(false); + var text = (result.ResultObject as AgentResponse)?.Text ?? result.ResultObject?.ToString(); + return Results.Text(text ?? string.Empty); + }); + + endpoints.MapPost("/stream", async (HttpContext http) => + { + var input = await new System.IO.StreamReader(http.Request.Body).ReadToEndAsync(http.RequestAborted).ConfigureAwait(false); + var request = new ChannelRequest("probe", "message.create", input) { Stream = true }; + var updates = 0; + var completed = 0; + await foreach (var item in context.StreamAsync(request, http.RequestAborted).ConfigureAwait(false)) + { + if (item is HostedStreamUpdate) { updates++; } + else if (item is HostedStreamCompleted) { completed++; } + } + return Results.Text($"updates={updates};completed={completed}"); + }); + }, + ], + }; + + private sealed class HeaderStampFilter : IEndpointFilter + { + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + context.HttpContext.Response.Headers["x-probe-filter"] = "applied"; + return await next(context).ConfigureAwait(false); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/RecordingAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/RecordingAgent.cs new file mode 100644 index 00000000000..2a0b679e60a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/RecordingAgent.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Agent that records the it was invoked with (unwrapped from +/// ) and echoes the user text. Used to prove option forwarding: by +/// default the channel strips parsed options, so stays ; +/// a custom run hook that keeps them makes them visible here. +/// +internal sealed class RecordingAgent : AIAgent +{ + public ChatOptions? LastChatOptions { get; private set; } + + public bool RunCalled { get; private set; } + + protected override string? IdCore => "recording-agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new RecordingSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new RecordingSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + this.Record(options); + var text = string.Concat(messages.Where(m => m.Role == ChatRole.User).Select(m => m.Text)); + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, text))); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.Record(options); + var text = string.Concat(messages.Where(m => m.Role == ChatRole.User).Select(m => m.Text)); + yield return new AgentResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent(text)] }; + await Task.Yield(); + } + + private void Record(AgentRunOptions? options) + { + this.RunCalled = true; + this.LastChatOptions = (options as ChatClientAgentRunOptions)?.ChatOptions; + } + + private sealed class RecordingSession : AgentSession + { + public RecordingSession() + { + } + + public RecordingSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/RecordingResponseHook.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/RecordingResponseHook.cs new file mode 100644 index 00000000000..3e172710297 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/RecordingResponseHook.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// Response hook that records whether it was invoked and returns the result unchanged. +internal sealed class RecordingResponseHook : IChannelResponseHook +{ + public bool Invoked { get; private set; } + + public ValueTask OnResponseAsync(HostedRunResult result, ChannelResponseContext context, CancellationToken cancellationToken) + { + this.Invoked = true; + return new ValueTask(result); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/RichContentAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/RichContentAgent.cs new file mode 100644 index 00000000000..57826d30e21 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/RichContentAgent.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Agent that returns a single assistant message carrying mixed content — reasoning, a function call, a +/// function result, and final text — so the channel's rich output-item rendering can be exercised. +/// +internal sealed class RichContentAgent : AIAgent +{ + public const string ReasoningText = "thinking about it"; + public const string CallId = "call_1"; + public const string ToolName = "get_weather"; + public const string FinalText = "It is sunny."; + + protected override string? IdCore => "rich-agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new RichSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new RichSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var content = new List + { + new TextReasoningContent(ReasoningText), + new FunctionCallContent(CallId, ToolName, new Dictionary { ["city"] = "Seattle" }), + new FunctionResultContent(CallId, "sunny in Seattle"), + new TextContent(FinalText), + }; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, content))); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return new AgentResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent(FinalText)] }; + await Task.Yield(); + } + + private sealed class RichSession : AgentSession + { + public RichSession() + { + } + + public RichSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ScriptedAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ScriptedAgent.cs new file mode 100644 index 00000000000..4a054264a29 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ScriptedAgent.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Agent that replays a fixed list of assistant objects as its response, so the +/// channel's output-item rendering (multimodal content, call/result coalescing, raw-item passthrough) can be +/// driven with arbitrary content. No live model. +/// +internal sealed class ScriptedAgent : AIAgent +{ + private readonly List _messages; + + public ScriptedAgent(params AIContent[] contents) + : this([new ChatMessage(ChatRole.Assistant, [.. contents])]) + { + } + + public ScriptedAgent(List messages) + { + this._messages = messages; + } + + protected override string? IdCore => "scripted-agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new ScriptedSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new ScriptedSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + Task.FromResult(new AgentResponse(this._messages)); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var message in this._messages) + { + yield return new AgentResponseUpdate { Role = ChatRole.Assistant, Contents = message.Contents }; + await Task.Yield(); + } + } + + private sealed class ScriptedSession : AgentSession + { + public ScriptedSession() + { + } + + public ScriptedSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/SpanCapturingAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/SpanCapturingAgent.cs new file mode 100644 index 00000000000..5e1ae555077 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/SpanCapturingAgent.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Agent that records the ambient trace id observed while it streams, so tests +/// can assert the deferred SSE stream runs under the request's parent span. No live model. +/// +internal sealed class SpanCapturingAgent : AIAgent +{ + public ActivityTraceId ObservedTraceId { get; private set; } + + protected override string? IdCore => "span-capturing-agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new SpanSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new SpanSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + this.ObservedTraceId = Activity.Current?.TraceId ?? default; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.ObservedTraceId = Activity.Current?.TraceId ?? default; + yield return new AgentResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent("ok")] }; + await Task.Yield(); + } + + private sealed class SpanSession : AgentSession + { + public SpanSession() + { + } + + public SpanSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/Sse.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/Sse.cs new file mode 100644 index 00000000000..5d64232aeb7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/Sse.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// Tiny Server-Sent-Events frame parser for assertions. +internal static class Sse +{ + public static IReadOnlyList<(string Event, string Data)> Parse(string body) + { + var frames = new List<(string, string)>(); + foreach (var block in body.Replace("\r\n", "\n").Split("\n\n", StringSplitOptions.RemoveEmptyEntries)) + { + string? evt = null; + var data = new List(); + foreach (var line in block.Split('\n')) + { + if (line.StartsWith("event:", StringComparison.Ordinal)) { evt = line["event:".Length..].Trim(); } + else if (line.StartsWith("data:", StringComparison.Ordinal)) { data.Add(line["data:".Length..].Trim()); } + } + + if (evt is not null) + { + frames.Add((evt, string.Join("\n", data))); + } + } + + return frames; + } + + public static IEnumerable Events(this IReadOnlyList<(string Event, string Data)> frames) => frames.Select(f => f.Event); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs new file mode 100644 index 00000000000..bf682d5ba40 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// Run hook that rewrites the request input, to assert run-hook ordering (before the target). +internal sealed class RewriteInputRunHook(string replacement) : IChannelRunHook +{ + public ValueTask OnRequestAsync(ChannelRequest request, ChannelRunHookContext context, CancellationToken cancellationToken) => + new(new ChannelRequest(request) { Input = replacement }); +} + +/// +/// Run hook that stamps from a request header, simulating trusted +/// middleware. The channel never trusts wire input for the isolation key; this is how a host derives it. +/// +internal sealed class HeaderIsolationRunHook(IHttpContextAccessor accessor, string headerName) : IChannelRunHook +{ + public ValueTask OnRequestAsync(ChannelRequest request, ChannelRunHookContext context, CancellationToken cancellationToken) + { + var key = accessor.HttpContext?.Request.Headers[headerName].ToString(); + if (string.IsNullOrEmpty(key)) + { + return new(request); + } + + var session = new ChannelSession(request.Session ?? new ChannelSession()) { IsolationKey = key }; + return new(new ChannelRequest(request) { Session = session }); + } +} + +/// Response hook that uppercases the agent reply, to assert response-hook ordering (before serialize). +internal sealed class UppercaseResponseHook : IChannelResponseHook +{ + public ValueTask OnResponseAsync(HostedRunResult result, ChannelResponseContext context, CancellationToken cancellationToken) + { + if (result is HostedRunResult typed) + { + var upper = typed.Result.Text.ToUpperInvariant(); + return new(typed.Replace(new AgentResponse(new ChatMessage(ChatRole.Assistant, upper)))); + } + + return new(result); + } +} + +/// Stream transform hook that prefixes a marker chunk, to assert the hook is applied while streaming. +internal sealed class PrefixStreamHook : IChannelStreamTransformHook +{ + public async IAsyncEnumerable TransformAsync( + IAsyncEnumerable upstream, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new AgentResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent("[X]")] }; + await foreach (var update in upstream.ConfigureAwait(false)) + { + yield return update; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHostApp.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHostApp.cs new file mode 100644 index 00000000000..3020243c07b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHostApp.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// In-process ASP.NET Core hosting an . Build via +/// , issue HTTP requests through , and dispose to stop (which +/// fires channel shutdown callbacks). +/// +internal sealed class TestHostApp : IAsyncDisposable +{ + private WebApplication _app = null!; + + public HttpClient Client { get; private set; } = null!; + + public IServiceProvider Services => this._app.Services; + + /// + /// Build and start a test host. adds the target + channels; + /// registers any extra services (e.g. http context accessor). + /// + public static async Task StartAsync( + Func configureHost, + Action? configureServices = null, + Action? configureApp = null) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + configureServices?.Invoke(builder.Services); + + configureHost(builder); + + var app = builder.Build(); + configureApp?.Invoke(app); + app.MapAgentFrameworkHost(); + await app.StartAsync().ConfigureAwait(false); + + var server = app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found."); + + return new TestHostApp + { + _app = app, + Client = server.CreateClient(), + }; + } + + public async ValueTask DisposeAsync() + { + this.Client?.Dispose(); + await this._app.StopAsync().ConfigureAwait(false); + await this._app.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ThrowingStreamAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ThrowingStreamAgent.cs new file mode 100644 index 00000000000..26a7f8c50be --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ThrowingStreamAgent.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Agent whose streaming run yields one update and then throws, to exercise the channel's mid-stream +/// failure path (a Responses response.failed SSE event). +/// +internal sealed class ThrowingStreamAgent : AIAgent +{ + public const string PartialText = "partial"; + public const string ErrorMessage = "boom mid-stream"; + + protected override string? IdCore => "throwing-agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new ThrowingSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new ThrowingSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, PartialText))); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return new AgentResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent(PartialText)] }; + await Task.Yield(); + throw new InvalidOperationException(ErrorMessage); + } + + private sealed class ThrowingSession : AgentSession + { + public ThrowingSession() + { + } + + public ThrowingSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/WorkflowFactory.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/WorkflowFactory.cs new file mode 100644 index 00000000000..0742d8d1e9f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/WorkflowFactory.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// Builds a trivial single-executor echo workflow for target-neutrality tests. +internal static class WorkflowFactory +{ + public static Workflow Echo() + { + var echo = new EchoExecutor(); + return new WorkflowBuilder(echo).WithOutputFrom(echo).Build(); + } + + private sealed class EchoExecutor() : Executor("EchoExecutor") + { + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + ValueTask.FromResult($"echo: {message}"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/WorkflowCheckpointTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/WorkflowCheckpointTests.cs new file mode 100644 index 00000000000..ed90d3ad3cf --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/WorkflowCheckpointTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Covers checkpoint behavior (Python parity with +/// test_invoke_writes_checkpoint_under_isolation_key / test_stream_writes_checkpoint_under_isolation_key): +/// with a persistent a run commits a checkpoint under the per-isolation-key +/// location and surfaces the resume id; with the in-memory store the runner runs forward without checkpointing. +/// +public class WorkflowCheckpointTests +{ + private static ChannelRequest Request(string isolationKey, IReadOnlyDictionary? attributes = null) => + new("test", "message.create", "hi") + { + Session = new ChannelSession { IsolationKey = isolationKey }, + Attributes = attributes ?? new Dictionary(), + }; + + private static string PerKeyDir(string root, string isolationKey) => + Path.Combine(root, "checkpoints", Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(isolationKey))); + + [Fact] + public async Task FileStore_RunCommitsCheckpointAndSurfacesIdAsync() + { + var root = Path.Combine(Path.GetTempPath(), "afhost-cp-" + Guid.NewGuid().ToString("N")); + try + { + using var store = new FileHostStateStore(new HostStatePathOptions { Root = root }); + var runner = new WorkflowRunner(WorkflowFactory.Echo(), store); + + var result = await runner.RunAsync(Request("alice"), default); + + var wr = Assert.IsType(result.ResultObject); + Assert.Equal(WorkflowRunStatus.Completed, wr.Status); + Assert.Contains("echo: hi", wr.Outputs.Select(o => o?.ToString())); + + // a real checkpoint file (not just the index) was committed under the per-isolation-key location + Assert.Contains(Directory.EnumerateFiles(PerKeyDir(root, "alice")), f => Path.GetFileName(f) != "index.jsonl"); + // the resume id is surfaced for the next turn + Assert.True(result.Session!.Attributes.ContainsKey(WorkflowRunner.CheckpointIdAttribute)); + } + finally + { + if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } + } + } + + [Fact] + public async Task FileStore_StreamCommitsCheckpointAsync() + { + var root = Path.Combine(Path.GetTempPath(), "afhost-cp-" + Guid.NewGuid().ToString("N")); + try + { + using var store = new FileHostStateStore(new HostStatePathOptions { Root = root }); + var runner = new WorkflowRunner(WorkflowFactory.Echo(), store); + + // Act - drive the streaming path + await foreach (var _ in runner.StreamAsync(Request("bob"), default)) + { + } + + // Assert - the streamed run also committed a checkpoint under its isolation key + Assert.Contains(Directory.EnumerateFiles(PerKeyDir(root, "bob")), f => Path.GetFileName(f) != "index.jsonl"); + } + finally + { + if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } + } + } + + [Fact] + public async Task FileStore_ResumesFromSurfacedCheckpointIdAsync() + { + var root = Path.Combine(Path.GetTempPath(), "afhost-cp-" + Guid.NewGuid().ToString("N")); + try + { + string checkpointId; + + // First turn: run, commit a checkpoint, and capture the surfaced resume id. The store is disposed + // before the second turn so the exclusive index.jsonl lock is released (avoids a self-conflict). + using (var store = new FileHostStateStore(new HostStatePathOptions { Root = root })) + { + var runner = new WorkflowRunner(WorkflowFactory.Echo(), store); + var first = await runner.RunAsync(Request("carol"), default); + checkpointId = Assert.IsType(first.Session!.Attributes[WorkflowRunner.CheckpointIdAttribute]); + } + + Assert.False(string.IsNullOrEmpty(checkpointId)); + + // Second turn: pass the captured id back on the request attributes; the runner resumes from it. + using (var store = new FileHostStateStore(new HostStatePathOptions { Root = root })) + { + var runner = new WorkflowRunner(WorkflowFactory.Echo(), store); + var attributes = new Dictionary { [WorkflowRunner.CheckpointIdAttribute] = checkpointId }; + var second = await runner.RunAsync(Request("carol", attributes), default); + + var wr = Assert.IsType(second.ResultObject); + Assert.Equal(WorkflowRunStatus.Completed, wr.Status); + } + } + finally + { + if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } + } + } + + [Fact] + public async Task InMemoryStore_RunsWithoutCheckpointingAsync() + { + // Arrange - the in-memory store yields no persistent location, so no checkpointing happens + var runner = new WorkflowRunner(WorkflowFactory.Echo(), new InMemoryHostStateStore()); + + // Act + var result = await runner.RunAsync(Request("alice"), default); + + // Assert - completed with real output, but no surfaced checkpoint id + var wr = Assert.IsType(result.ResultObject); + Assert.Equal(WorkflowRunStatus.Completed, wr.Status); + Assert.Contains("echo: hi", wr.Outputs.Select(o => o?.ToString())); + Assert.False(result.Session!.Attributes.ContainsKey(WorkflowRunner.CheckpointIdAttribute)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelCommandTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelCommandTests.cs new file mode 100644 index 00000000000..9421cbe3a2d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelCommandTests.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +/// +/// Covers the channel command contract: a carries a handler that receives a +/// (the originating request plus a reply callback), mirroring the Python +/// host's command/handle/reply seam. In v1 the channel self-dispatches; the host does not own a command loop. +/// +public class ChannelCommandTests +{ + [Fact] + public async Task Handler_ReceivesContext_AndReplyRoundTripsAsync() + { + // Arrange + var request = new ChannelRequest("probe", "command.invoke", "/reset now"); + ChannelCommandContext? seen = null; + string? replied = null; + + var command = new ChannelCommand("reset", "Start a fresh session", async ctx => + { + seen = ctx; + await ctx.Reply($"reset for {ctx.Request.Channel}").ConfigureAwait(false); + }); + + var context = new ChannelCommandContext(request, msg => { replied = msg; return default; }); + + // Act - the channel dispatches the matched command + await command.Handler(context); + + // Assert + Assert.Same(context, seen); + Assert.Same(request, seen!.Request); + Assert.Equal("reset for probe", replied); + } + + [Fact] + public void Command_Properties_AreExposed() + { + // Arrange / Act + var command = new ChannelCommand("reset", "Start a fresh session", _ => default); + + // Assert + Assert.Equal("reset", command.Name); + Assert.Equal("Start a fresh session", command.Description); + Assert.NotNull(command.Handler); + } + + [Fact] + public void Command_NullName_Throws() + { + // Act / Assert + Assert.Throws(() => new ChannelCommand(null!, "d", _ => default)); + } + + [Fact] + public void Command_EmptyName_Throws() + { + // Act / Assert + Assert.Throws(() => new ChannelCommand("", "d", _ => default)); + } + + [Fact] + public void Command_NullDescription_Throws() + { + // Act / Assert + Assert.Throws(() => new ChannelCommand("reset", null!, _ => default)); + } + + [Fact] + public void Command_NullHandler_Throws() + { + // Act / Assert + Assert.Throws(() => new ChannelCommand("reset", "d", null!)); + } + + [Fact] + public void Context_NullRequest_Throws() + { + // Act / Assert + Assert.Throws(() => new ChannelCommandContext(null!, _ => default)); + } + + [Fact] + public void Context_NullReply_Throws() + { + // Arrange + var request = new ChannelRequest("probe", "command.invoke", "/reset"); + + // Act / Assert + Assert.Throws(() => new ChannelCommandContext(request, null!)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelRequestTests.cs new file mode 100644 index 00000000000..9d7668c407e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelRequestTests.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class ChannelRequestTests +{ + [Fact] + public void ChannelRequest_DefaultsAreMinimal() + { + // Arrange / Act + var request = new ChannelRequest("responses", "message.create", "hello"); + + // Assert + Assert.Equal(SessionMode.Auto, request.SessionMode); + Assert.False(request.Stream); + Assert.Null(request.Session); + Assert.Null(request.Identity); + Assert.Empty(request.Attributes); + Assert.Empty(request.Metadata); + } + + [Fact] + public void ChannelSession_AllFieldsNullable() + { + // Arrange / Act + var session = new ChannelSession(); + + // Assert + Assert.Null(session.Key); + Assert.Null(session.ConversationId); + Assert.Null(session.IsolationKey); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostCompositionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostCompositionTests.cs new file mode 100644 index 00000000000..90df6824c38 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostCompositionTests.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class HostCompositionTests +{ + [Fact] + public void AddAgentFrameworkHost_WithChannel_ComposesHost() + { + // Arrange + var builder = Host.CreateApplicationBuilder(); + var echo = new EchoExecutor(); + var workflow = new WorkflowBuilder(echo).WithOutputFrom(echo).Build(); + + // Act + builder.AddAgentFrameworkHost(workflow).AddChannel(new FakeChannel()); + using var app = builder.Build(); + var host = app.Services.GetRequiredService(); + + // Assert + Assert.Single(host.Channels); + Assert.Equal("fake", host.Channels[0].Name); + Assert.IsType(host.TargetRunner); + } + + [Fact] + public async Task Host_RunAsync_DrivesWorkflowTargetAsync() + { + // Arrange + var builder = Host.CreateApplicationBuilder(); + var echo = new EchoExecutor(); + var workflow = new WorkflowBuilder(echo).WithOutputFrom(echo).Build(); + builder.AddAgentFrameworkHost(workflow).AddChannel(new FakeChannel()); + using var app = builder.Build(); + var host = app.Services.GetRequiredService(); + + // Act + var result = await host.RunAsync(new ChannelRequest("fake", "message.create", "hi"), CancellationToken.None); + + // Assert + var typed = Assert.IsType>(result); + Assert.Equal(WorkflowRunStatus.Completed, typed.Result.Status); + } + + private sealed class FakeChannel : Channel + { + public override string Name => "fake"; + public override ChannelContribution Contribute(ChannelContext context) => new(); + } + + private sealed class EchoExecutor() : Executor("EchoExecutor") + { + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + ValueTask.FromResult($"echo: {message}"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostStateStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostStateStoreTests.cs new file mode 100644 index 00000000000..433e1473d6c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostStateStoreTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class HostStateStoreTests +{ + [Fact] + public async Task InMemory_GetActiveAlias_StableUntilRotatedAsync() + { + // Arrange + var store = new InMemoryHostStateStore(); + + // Act + var first = await store.GetActiveSessionAliasAsync("user:alice", CancellationToken.None); + var second = await store.GetActiveSessionAliasAsync("user:alice", CancellationToken.None); + await store.RotateSessionAliasAsync("user:alice", CancellationToken.None); + var rotated = await store.GetActiveSessionAliasAsync("user:alice", CancellationToken.None); + + // Assert + Assert.Equal(first, second); + Assert.NotEqual(first, rotated); + } + + [Fact] + public async Task InMemory_CheckpointLocation_IsNullAsync() + { + // Arrange + var store = new InMemoryHostStateStore(); + + // Act - the in-memory store does not persist checkpoints + var location = await store.GetCheckpointLocationAsync("k1", CancellationToken.None); + + // Assert + Assert.Null(location); + } + + [Fact] + public async Task File_Alias_PersistsAcrossInstancesAsync() + { + // Arrange + var root = Path.Combine(Path.GetTempPath(), "afhost-test-" + Guid.NewGuid().ToString("N")); + try + { + string alias1; + using (var store1 = new FileHostStateStore(new HostStatePathOptions { Root = root })) + { + await store1.RotateSessionAliasAsync("user:bob", CancellationToken.None); + alias1 = await store1.GetActiveSessionAliasAsync("user:bob", CancellationToken.None) ?? string.Empty; + } + + // Act - a fresh instance over the same directory (after the first released its lock) + using var store2 = new FileHostStateStore(new HostStatePathOptions { Root = root }); + var alias2 = await store2.GetActiveSessionAliasAsync("user:bob", CancellationToken.None); + + // Assert + Assert.Equal(alias1, alias2); + } + finally + { + if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } + } + } + + [Fact] + public void File_SecondInstanceSameRoot_ThrowsWhileLocked() + { + // Arrange + var root = Path.Combine(Path.GetTempPath(), "afhost-test-" + Guid.NewGuid().ToString("N")); + try + { + using var store1 = new FileHostStateStore(new HostStatePathOptions { Root = root }); + + // Act / Assert - a second owner of the same directory fails fast + Assert.Throws(() => new FileHostStateStore(new HostStatePathOptions { Root = root })); + } + finally + { + if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } + } + } + + [Theory] + [InlineData("../escape")] + [InlineData("a/b")] + [InlineData("a\\b")] + [InlineData("..")] + [InlineData("C:evil")] + public async Task File_CheckpointLocation_RejectsTraversalAsync(string badKey) + { + // Arrange + var root = Path.Combine(Path.GetTempPath(), "afhost-test-" + Guid.NewGuid().ToString("N")); + try + { + using var store = new FileHostStateStore(new HostStatePathOptions { Root = root }); + + // Act / Assert + await Assert.ThrowsAsync(async () => await store.GetCheckpointLocationAsync(badKey, CancellationToken.None)); + } + finally + { + if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } + } + } + + [Fact] + public async Task File_CheckpointLocation_AllowsNamespacedKeyAsync() + { + // Arrange + var root = Path.Combine(Path.GetTempPath(), "afhost-test-" + Guid.NewGuid().ToString("N")); + try + { + using var store = new FileHostStateStore(new HostStatePathOptions { Root = root }); + + // Act - a legitimate namespaced key is preserved and yields a directory under the root + var location = await store.GetCheckpointLocationAsync("telegram:42", CancellationToken.None); + + // Assert + Assert.NotNull(location); + Assert.True(Directory.Exists(location)); + } + finally + { + if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/IsolationKeysTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/IsolationKeysTests.cs new file mode 100644 index 00000000000..fa3abb07774 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/IsolationKeysTests.cs @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +/// +/// Covers the per-request isolation surface: the value type, the +/// AsyncLocal slot, the public header constants, the DI accessor, and the +/// header-lifting endpoint filter exercised directly. The filter is the ONLY seam Foundry-aware providers use +/// to find partition keys, so a regression here silently misroutes writes or leaks per-request state. +/// +public class IsolationKeysTests +{ + [Fact] + public void Defaults_AreEmpty() + { + // Arrange / Act + var keys = new IsolationKeys(null, null); + + // Assert + Assert.Null(keys.UserKey); + Assert.Null(keys.ChatKey); + Assert.True(keys.IsEmpty); + } + + [Fact] + public void UserKeyOnly_IsNotEmpty() + { + // Arrange / Act + var keys = new IsolationKeys("alice", null); + + // Assert + Assert.Equal("alice", keys.UserKey); + Assert.Null(keys.ChatKey); + Assert.False(keys.IsEmpty); + } + + [Fact] + public void ChatKeyOnly_IsNotEmpty() + { + // Arrange / Act + var keys = new IsolationKeys(null, "general"); + + // Assert + Assert.False(keys.IsEmpty); + } + + [Fact] + public void FullPair_IsNotEmpty() + { + // Arrange / Act + var keys = new IsolationKeys("alice", "general"); + + // Assert + Assert.False(keys.IsEmpty); + } + + [Fact] + public void Current_DefaultsToNull() + { + // Arrange + IsolationKeys.Current = null; + + // Assert + Assert.Null(IsolationKeys.Current); + } + + [Fact] + public void Current_SetGetReset_RoundTrips() + { + // Arrange + var previous = IsolationKeys.Current; + + // Act + IsolationKeys.Current = new IsolationKeys("alice", "general"); + + // Assert + Assert.NotNull(IsolationKeys.Current); + Assert.Equal("alice", IsolationKeys.Current!.UserKey); + Assert.Equal("general", IsolationKeys.Current.ChatKey); + + // Reset + IsolationKeys.Current = previous; + Assert.Null(IsolationKeys.Current); + } + + [Fact] + public void Current_SetNull_Clears() + { + // Arrange + IsolationKeys.Current = new IsolationKeys("alice", null); + + // Act + IsolationKeys.Current = null; + + // Assert + Assert.Null(IsolationKeys.Current); + } + + [Fact] + public void HeaderConstants_MatchFoundryContract() + { + // Assert + Assert.Equal("x-agent-user-isolation-key", IsolationKeys.UserHeader); + Assert.Equal("x-agent-chat-isolation-key", IsolationKeys.ChatHeader); + } + + [Fact] + public void Accessor_ReadsCurrent() + { + // Arrange + var accessor = new IsolationKeysAccessor(); + IsolationKeys.Current = new IsolationKeys("bob", null); + + // Act + var via = accessor.Current; + + // Assert + Assert.NotNull(via); + Assert.Equal("bob", via!.UserKey); + + // Cleanup + IsolationKeys.Current = null; + Assert.Null(accessor.Current); + } + + [Fact] + public async Task Filter_BothHeaders_BindsThenResetsAsync() + { + // Arrange + IsolationKeys.Current = null; + var filter = new IsolationKeysEndpointFilter(); + var http = new DefaultHttpContext(); + http.Request.Headers[IsolationKeys.UserHeader] = "alice-uid"; + http.Request.Headers[IsolationKeys.ChatHeader] = "general-cid"; + IsolationKeys? captured = null; + + // Act + var result = await filter.InvokeAsync( + new TestFilterContext(http), + _ => { captured = IsolationKeys.Current; return ValueTask.FromResult("ok"); }); + + // Assert - bound inside, reset after + Assert.NotNull(captured); + Assert.Equal("alice-uid", captured!.UserKey); + Assert.Equal("general-cid", captured.ChatKey); + Assert.Equal("ok", result); + Assert.Null(IsolationKeys.Current); + } + + [Fact] + public async Task Filter_OnlyUserHeader_BindsChatNullAsync() + { + // Arrange + var filter = new IsolationKeysEndpointFilter(); + var http = new DefaultHttpContext(); + http.Request.Headers[IsolationKeys.UserHeader] = "alice-uid"; + IsolationKeys? captured = null; + + // Act + await filter.InvokeAsync( + new TestFilterContext(http), + _ => { captured = IsolationKeys.Current; return ValueTask.FromResult(null); }); + + // Assert + Assert.NotNull(captured); + Assert.Equal("alice-uid", captured!.UserKey); + Assert.Null(captured.ChatKey); + } + + [Fact] + public async Task Filter_OnlyChatHeader_BindsUserNullAsync() + { + // Arrange + var filter = new IsolationKeysEndpointFilter(); + var http = new DefaultHttpContext(); + http.Request.Headers[IsolationKeys.ChatHeader] = "general-cid"; + IsolationKeys? captured = null; + + // Act + await filter.InvokeAsync( + new TestFilterContext(http), + _ => { captured = IsolationKeys.Current; return ValueTask.FromResult(null); }); + + // Assert + Assert.NotNull(captured); + Assert.Null(captured!.UserKey); + Assert.Equal("general-cid", captured.ChatKey); + } + + [Fact] + public async Task Filter_NoHeaders_IsNoopAsync() + { + // Arrange + IsolationKeys.Current = null; + var filter = new IsolationKeysEndpointFilter(); + var http = new DefaultHttpContext(); + IsolationKeys? captured = new("sentinel", null); + + // Act + await filter.InvokeAsync( + new TestFilterContext(http), + _ => { captured = IsolationKeys.Current; return ValueTask.FromResult(null); }); + + // Assert - never bound + Assert.Null(captured); + Assert.Null(IsolationKeys.Current); + } + + [Fact] + public async Task Filter_EmptyHeaderValue_TreatedAsAbsentAsync() + { + // Arrange - empty user header must not bind an empty key; chat still binds + var filter = new IsolationKeysEndpointFilter(); + var http = new DefaultHttpContext(); + http.Request.Headers[IsolationKeys.UserHeader] = ""; + http.Request.Headers[IsolationKeys.ChatHeader] = "general-cid"; + IsolationKeys? captured = null; + + // Act + await filter.InvokeAsync( + new TestFilterContext(http), + _ => { captured = IsolationKeys.Current; return ValueTask.FromResult(null); }); + + // Assert + Assert.NotNull(captured); + Assert.Null(captured!.UserKey); + Assert.Equal("general-cid", captured.ChatKey); + } + + [Fact] + public async Task Filter_RestoresPreviousValueAsync() + { + // Arrange - a pre-existing Current must be restored, not clobbered to null + var outer = new IsolationKeys("outer-user", null); + IsolationKeys.Current = outer; + var filter = new IsolationKeysEndpointFilter(); + var http = new DefaultHttpContext(); + http.Request.Headers[IsolationKeys.UserHeader] = "inner-user"; + + // Act + await filter.InvokeAsync( + new TestFilterContext(http), + _ => ValueTask.FromResult(null)); + + // Assert - restored to outer + Assert.Same(outer, IsolationKeys.Current); + + // Cleanup + IsolationKeys.Current = null; + } + + [Fact] + public async Task Filter_PropagatesInnerResultAsync() + { + // Arrange + var filter = new IsolationKeysEndpointFilter(); + var http = new DefaultHttpContext(); + http.Request.Headers[IsolationKeys.UserHeader] = "alice"; + + // Act + var result = await filter.InvokeAsync( + new TestFilterContext(http), + _ => ValueTask.FromResult(42)); + + // Assert + Assert.Equal(42, result); + } + + private sealed class TestFilterContext(HttpContext httpContext) : EndpointFilterInvocationContext + { + public override HttpContext HttpContext { get; } = httpContext; + + public override IList Arguments { get; } = []; + + public override T GetArgument(int index) => (T)this.Arguments[index]!; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/Microsoft.Agents.AI.Hosting.Channels.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/Microsoft.Agents.AI.Hosting.Channels.UnitTests.csproj new file mode 100644 index 00000000000..3ac66345803 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/Microsoft.Agents.AI.Hosting.Channels.UnitTests.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/WorkflowRunnerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/WorkflowRunnerTests.cs new file mode 100644 index 00000000000..7f9fe1f263c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/WorkflowRunnerTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class WorkflowRunnerTests +{ + [Fact] + public async Task RunAsync_EchoWorkflow_RunsToCompletionAsync() + { + // Arrange + var echo = new EchoExecutor(); + var workflow = new WorkflowBuilder(echo).WithOutputFrom(echo).Build(); + var runner = new WorkflowRunner(workflow); + var request = new ChannelRequest("test", "message.create", "ping"); + + // Act + var result = await runner.RunAsync(request, CancellationToken.None); + + // Assert + var typed = Assert.IsType>(result); + Assert.Equal(WorkflowRunStatus.Completed, typed.Result.Status); + Assert.False(string.IsNullOrEmpty(typed.Result.SessionId)); + } + + [Fact] + public async Task RunAsync_RequestPortWorkflow_ReturnsAwaitingInputAsync() + { + // Arrange - a workflow that forwards its input to a RequestPort, pausing for external input + var port = RequestPort.Create("ask"); + var forward = new ForwardExecutor(); + var workflow = new WorkflowBuilder(forward).AddEdge(forward, port).Build(); + var runner = new WorkflowRunner(workflow); + var request = new ChannelRequest("test", "command.invoke", "hi"); + + // Act + var result = await runner.RunAsync(request, CancellationToken.None); + + // Assert - the RequestInfoEvent surfaces as AwaitingInput with the pending request captured + var typed = Assert.IsType>(result); + Assert.Equal(WorkflowRunStatus.AwaitingInput, typed.Result.Status); + Assert.NotNull(typed.Result.PendingRequest); + } + + private sealed class EchoExecutor() : Executor("EchoExecutor") + { + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + ValueTask.FromResult($"echo: {message}"); + } + + private sealed class ForwardExecutor() : Executor("ForwardExecutor") + { + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + ValueTask.FromResult(message); + } +}