diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
index 538d424ad4f..0472fcb38ff 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Linq;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Threading;
@@ -33,6 +34,16 @@ public class AgentFrameworkResponseHandler : ResponseHandler
///
private static readonly HostedSessionIsolationKeyProvider s_defaultIsolationKeyProvider = new PlatformHostedSessionIsolationKeyProvider();
+ /// Identifies the handler as the source of chat history messages it passes as input.
+ private const string HistorySourceId = "Microsoft.Agents.AI.Foundry.Hosting.AgentFrameworkResponseHandler";
+
+ ///
+ /// The session type a hosted workflow runs with. It is internal to Microsoft.Agents.AI.Workflows,
+ /// so it is recognised by name: taking a reference to it would mean opening that package's internals,
+ /// which cannot be done here because both packages compile the same shared source files.
+ ///
+ private const string WorkflowSessionTypeName = "WorkflowSession";
+
///
/// Initializes a new instance of the class
/// that resolves agents from keyed DI services.
@@ -112,16 +123,21 @@ public override async IAsyncEnumerable CreateAsync(
// (resolvedUserId is null) there is no user to partition on, so the session is unscoped/shared
// by design — per-user isolation applies only when a user identity was resolved (hosted).
var conversationId = request.GetConversationId();
- var sessionConversationId = HostedConversationKey.Resolve(
+ var agentSessionId = HostedConversationKey.Resolve(
conversationId, request.PreviousResponseId, context.ResponseId);
- var chatClientAgent = agent.GetService();
+ var agentOptions = agent.GetService();
+
+ // Load an existing session when there is a conversation key. The store returns null when
+ // nothing is persisted for it, which is the authoritative "this is a resume" signal: a
+ // non-null result means a prior turn saved this session. Whether loaded or created, the
+ // handler owns creating a fresh session when none exists, so the resume signal does not
+ // depend on inspecting the session for state the handler itself also writes to.
+ AgentSession? sessionLoadedFromStore = !string.IsNullOrWhiteSpace(agentSessionId)
+ ? await sessionStore.GetSessionAsync(agent, agentSessionId, resolvedUserId, cancellationToken).ConfigureAwait(false)
+ : null;
- AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId)
- ? await sessionStore.GetSessionAsync(agent, sessionConversationId, resolvedUserId, cancellationToken).ConfigureAwait(false)
- : chatClientAgent is not null
- ? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
- : await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
+ AgentSession? session = sessionLoadedFromStore ?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
// Capture the platform per-request call id (x-agent-foundry-call-id, protocol 2.0.0 only).
// It is re-applied to the ambient HostedCallContext immediately before each outbound egress
@@ -153,6 +169,19 @@ public override async IAsyncEnumerable CreateAsync(
}
}
+ // A hosted agent's conversation is recorded by the AgentServer SDK's own storage provider. A
+ // conversation id on the session means the service behind the agent's chat client is recording
+ // a second one, which nothing here reads and which no one reconciles with the first. Refuse
+ // before any work is done, as a plain bad request rather than a failure part way through.
+ if (session is ChatClientAgentSession { ConversationId: not null })
+ {
+ throw new ResponsesApiException(
+ new Error(
+ "service_managed_chat_history_not_supported",
+ "Chat history is managed by the hosted agent service, therefore using a ChatClientAgent with its own service storage is not supported. Configure the agent's chat client so the underlying service does not store responses."),
+ 400);
+ }
+
// 3. Create the SDK event stream builder
var stream = new ResponseEventStream(context, request);
@@ -163,18 +192,17 @@ public override async IAsyncEnumerable CreateAsync(
// 4. Convert input: history + current input → ChatMessage[]
var messages = new List();
- // Load conversation history only for fresh sessions. When a session already exists
- // (e.g. resuming a workflow paused at an external-input port), the workflow's
- // checkpointed state already contains the prior turns' messages — replaying history
- // would re-drive completed actions and break HITL resume semantics.
- var isResume = (!string.IsNullOrWhiteSpace(conversationId) || !string.IsNullOrWhiteSpace(request.PreviousResponseId))
- && session?.StateBag?.Count > 0;
- if (!isResume)
+ // Add the chat history to the request. Workflow sessions accumulate previous turns and must not
+ // get the full history again; their types are internal, hence the check on the type name.
+ if (sessionLoadedFromStore is null
+ || !string.Equals(sessionLoadedFromStore.GetType().Name, WorkflowSessionTypeName, StringComparison.Ordinal))
{
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
{
- messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag));
+ messages.AddRange(InputConverter
+ .ConvertOutputItemsToMessages(history, session?.StateBag)
+ .Select(m => m.WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, HistorySourceId)));
}
}
@@ -191,9 +219,16 @@ public override async IAsyncEnumerable CreateAsync(
}
// 5. Build chat options
- var chatOptions = InputConverter.ConvertToChatOptions(request);
+ var chatOptions = InputConverter.ConvertToChatOptions(request, agentOptions?.ChatOptions?.RawRepresentationFactory);
chatOptions.Instructions = request.Instructions;
+ // Everything the agent needs for this turn is already in the input, so the provider it would
+ // otherwise run is replaced for the duration by one that keeps its messages in memory and is
+ // dropped when the run ends. Serving from a longer-lived one would deliver the conversation
+ // twice, and storing into it would leave a copy the hosting service never sees.
+ chatOptions.AdditionalProperties ??= [];
+ chatOptions.AdditionalProperties.Add(new VolatileChatHistoryProvider());
+
// Inject Foundry Toolbox tools when the toolbox service is available.
//
// Two sources are considered:
@@ -445,9 +480,9 @@ await this._toolboxService
// Persist session after streaming completes (successful or not). The user id partitions the
// persisted session per end user, mirroring the load above so multi-turn continuity is preserved.
- if (session is not null && !string.IsNullOrWhiteSpace(sessionConversationId))
+ if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId))
{
- await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
+ await sessionStore.SaveSessionAsync(agent, agentSessionId, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
}
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs
index d1c93dc274c..d507db4d966 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
@@ -42,7 +43,8 @@ public abstract ValueTask SaveSessionAsync(
CancellationToken cancellationToken = default);
///
- /// Retrieves a serialized agent session from persistent storage.
+ /// Retrieves a serialized agent session from persistent storage, or when
+ /// no session is stored for the given identifiers.
///
/// The agent that owns this session.
/// The unique identifier for the conversation/session to retrieve.
@@ -55,12 +57,41 @@ public abstract ValueTask SaveSessionAsync(
///
/// The to monitor for cancellation requests.
///
- /// A task that represents the asynchronous retrieval operation.
- /// The task result contains the session, or a new session if not found.
+ /// A task that represents the asynchronous retrieval operation. The task result contains the restored
+ /// session, or when nothing is stored for the given identifiers. This is a plain
+ /// lookup: it never creates a session. Use to get a ready-to-use
+ /// session (loading an existing one or creating a new one), and use this method when the caller needs to
+ /// distinguish a resumed session from a fresh one (a non-null result means a prior turn established it).
///
- public abstract ValueTask GetSessionAsync(
+ public abstract ValueTask GetSessionAsync(
AIAgent agent,
string conversationId,
string? userId,
CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves the stored session for the given identifiers, or creates a new one via
+ /// when none is stored.
+ ///
+ /// The agent that owns this session.
+ /// The unique identifier for the conversation/session to retrieve.
+ /// The per-user partition key; see for its meaning.
+ /// The to monitor for cancellation requests.
+ /// A task whose result is always a usable session, never .
+ ///
+ /// This is the convenience path for callers that only need a session to work with and do not care whether
+ /// it was loaded or freshly created. It is implemented in terms of , so a
+ /// store overriding that method gets this behavior for free.
+ ///
+ public virtual async ValueTask GetOrCreateSessionAsync(
+ AIAgent agent,
+ string conversationId,
+ string? userId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(agent);
+
+ return await this.GetSessionAsync(agent, conversationId, userId, cancellationToken).ConfigureAwait(false)
+ ?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs
index 17c9a184a35..c7c3b7292d4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs
@@ -208,7 +208,7 @@ private string BuildNotWritableMessage(string sessionFilePath) =>
$"(for example {nameof(InMemoryAgentSessionStore)}) via AddFoundryResponses(agent, agentSessionStore).";
///
- public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
+ public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
@@ -216,13 +216,13 @@ public override async ValueTask GetSessionAsync(AIAgent agent, str
string path = this.GetSessionPath(agent, conversationId, userId);
if (!File.Exists(path))
{
- return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
+ return null;
}
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
if (bytes.Length == 0)
{
- return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
+ return null;
}
// Parse and clone so the document buffer can be released.
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs
index 3b3b52fda57..579240432b6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs
@@ -40,16 +40,15 @@ public override async ValueTask SaveSessionAsync(AIAgent agent, string conversat
}
///
- public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
+ public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
{
var key = GetKey(agent, conversationId, userId);
- JsonElement? sessionContent = this._sessions.TryGetValue(key, out var existingSession) ? existingSession : null;
-
- return sessionContent switch
+ if (!this._sessions.TryGetValue(key, out var existingSession))
{
- null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false),
- _ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false),
- };
+ return null;
+ }
+
+ return await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false);
}
// Keyed with the same a-/u-/c- prefix scheme as FileSystemAgentSessionStore so the in-memory store
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs
index e104487df76..3a4e95b041c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs
@@ -7,6 +7,8 @@
using System.Text.Json;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Extensions.AI;
+using ChatCompletionOptions = OpenAI.Chat.ChatCompletionOptions;
+using CreateResponseOptions = OpenAI.Responses.CreateResponseOptions;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
using SdkTextContent = Azure.AI.AgentServer.Responses.Models.TextContent;
@@ -87,10 +89,14 @@ public static List ConvertOutputItemsToMessages(IReadOnlyList from the SDK request properties.
///
/// The create response request.
+ ///
+ /// The factory the agent carries on its own , if any, so a request that has
+ /// to set one of its own can run it rather than replace it.
+ ///
/// A configured instance.
- public static ChatOptions ConvertToChatOptions(CreateResponse request)
+ public static ChatOptions ConvertToChatOptions(CreateResponse request, Func? agentRawRepresentationFactory = null)
{
- return new ChatOptions
+ var options = new ChatOptions
{
Temperature = (float?)request.Temperature,
TopP = (float?)request.TopP,
@@ -100,6 +106,43 @@ public static ChatOptions ConvertToChatOptions(CreateResponse request)
// the client-provided model would override it (causing failures when
// clients send placeholder values like "hosted-agent").
};
+
+ // The service behind the agent's chat client is never asked to store a response. Recording a
+ // hosted turn is the AgentServer SDK's job, done by its storage provider around this handler,
+ // and a second recording downstream is a conversation nothing here reads and no one reconciles.
+ // The caller's own store flag is not carried across: it says what the hosting service should
+ // record, which is a separate question and one this handler has no say in.
+ //
+ // Both OpenAI request shapes carry the setting, so a chat client speaking either protocol is
+ // covered. Anything else is a request type with no notion of storing a response, and is handed
+ // back untouched; such a client keeping a conversation of its own is caught later by the
+ // conversation id check in the handler.
+ //
+ // The agent's own factory is invoked here and its result is what gets the setting, because
+ // ChatClientAgent chains the two by taking the agent's only when the request's returns null
+ // (ChatClientAgent.PrepareChatOptions). A request factory that always answers would otherwise
+ // drop whatever the container configured.
+ options.RawRepresentationFactory = chatClient =>
+ {
+ switch (agentRawRepresentationFactory?.Invoke(chatClient))
+ {
+ case CreateResponseOptions responseOptions:
+ responseOptions.StoredOutputEnabled = false;
+ return responseOptions;
+
+ case ChatCompletionOptions completionOptions:
+ completionOptions.StoredOutputEnabled = false;
+ return completionOptions;
+
+ case { } configuredByTheAgent:
+ return configuredByTheAgent;
+
+ default:
+ return new CreateResponseOptions { StoredOutputEnabled = false };
+ }
+ };
+
+ return options;
}
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/VolatileChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/VolatileChatHistoryProvider.cs
new file mode 100644
index 00000000000..3fc912665df
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/VolatileChatHistoryProvider.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Foundry.Hosting;
+
+///
+/// A that holds the turn's messages in a field, for the lifetime of
+/// one request and no longer.
+///
+///
+///
+/// A hosted agent's conversation is recorded by the AgentServer SDK's own storage provider, which
+/// writes every turn the caller asked it to store and serves it back through
+/// . That happens around
+/// the handler, not through it. The handler reads the conversation from there and passes it in as the
+/// run's input, so nothing has to be carried between requests, and a provider storing anything of its
+/// own would only add a copy the storage provider never sees.
+///
+///
+/// Within a single run the provider still does its ordinary work: an agent calling tools goes back to
+/// the chat client several times, and each of those calls needs the messages the earlier ones produced.
+/// Those live here until the run ends and the instance is dropped.
+///
+///
+/// Supplied as a run-scoped override through , so it takes
+/// the place of the agent's own provider for the turn without changing the agent. An agent that does not
+/// read its history through a provider ignores it.
+///
+///
+internal sealed class VolatileChatHistoryProvider : ChatHistoryProvider
+{
+ private readonly List _messages = [];
+
+ ///
+ protected override ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ => new(this._messages);
+
+ ///
+ protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
+ {
+ // Only what this run produced arrives here: the base class filters out everything already marked
+ // as chat history, which covers the turns the handler took from the storage provider.
+ this._messages.AddRange(context.RequestMessages);
+ if (context.ResponseMessages is not null)
+ {
+ this._messages.AddRange(context.ResponseMessages);
+ }
+
+ return default;
+ }
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/DownstreamConversationReportingAgent.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/DownstreamConversationReportingAgent.cs
new file mode 100644
index 00000000000..899c4f0d360
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/DownstreamConversationReportingAgent.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+namespace Foundry.Hosting.IntegrationTests.TestContainer;
+
+///
+/// Wraps the container's agent and tells the caller which conversation the agent's own run left behind
+/// on the service, by appending DOWNSTREAM_ID=<id> to the reply.
+///
+///
+///
+/// The platform already records every hosted turn around the handler, and that is the conversation the
+/// caller reads. The agent's run inside the container talks to its own service, and if that service is
+/// asked to keep the turn it writes a second record, on a trail of its own that the caller never sees.
+///
+///
+/// After the run, the id of that trail is on the session, so reporting it is enough for a test to go
+/// look for it on the service. No id means the container asked for nothing to be kept.
+///
+///
+internal sealed class DownstreamConversationReportingAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent)
+{
+ ///
+ /// Marker that carries the id. Tests read the value that follows it.
+ ///
+ public const string IdPrefix = "DOWNSTREAM_ID=";
+
+ ///
+ /// Value reported when the agent's run left nothing behind on the service.
+ ///
+ public const string NoId = "none";
+
+ ///
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await foreach (var update in this.InnerAgent
+ .RunStreamingAsync(messages, session, options, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ yield return update;
+ }
+
+ var downstreamId = (session as ChatClientAgentSession)?.ConversationId;
+ yield return new AgentResponseUpdate(
+ ChatRole.Assistant,
+ $" {IdPrefix}{(string.IsNullOrWhiteSpace(downstreamId) ? NoId : downstreamId)}");
+ }
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
index 6bea914f978..6f491c7290f 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
@@ -6,6 +6,7 @@
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
+using Foundry.Hosting.IntegrationTests.TestContainer;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Agents.AI.Foundry.Hosting;
@@ -34,6 +35,7 @@
"happy-path" => CreateHappyPathAgent(projectClient, deployment),
"unsupported-protocol" => CreateHappyPathAgent(projectClient, deployment),
"store-config" => CreateStoreConfigAgent(projectClient, deployment),
+ "downstream-store" => CreateDownstreamStoreAgent(projectClient, deployment),
"tool-calling" => CreateToolCallingAgent(projectClient, deployment),
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
@@ -89,6 +91,19 @@ static AIAgent CreateStoreConfigAgent(AIProjectClient client, string deployment)
name: "store-config-agent",
description: "Store and session semantics test agent.");
+// downstream-store scenario: an ordinary Foundry ChatClientAgent, like the first hosted agent sample,
+// wrapped so the caller is told which conversation the agent's own run left behind on the service. The
+// platform already records the hosted turn in the caller's conversation; anything the agent's run also
+// leaves behind is a second copy of the same turn, on a trail nobody reads.
+static AIAgent CreateDownstreamStoreAgent(AIProjectClient client, string deployment) =>
+ new DownstreamConversationReportingAgent(
+ client.AsAIAgent(
+ model: deployment,
+ instructions: "You are a helpful assistant. Answer the user's question concisely and accurately, " +
+ "and use any facts the user told you earlier in the conversation.",
+ name: "downstream-store-agent",
+ description: "Downstream store test agent."));
+
static AIAgent CreateToolCallingAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/DownstreamStoreHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/DownstreamStoreHostedAgentFixture.cs
new file mode 100644
index 00000000000..d5033a1e913
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/DownstreamStoreHostedAgentFixture.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Foundry.Hosting.IntegrationTests.Fixtures;
+
+///
+/// Provisions a hosted agent that runs the test container in IT_SCENARIO=downstream-store mode.
+/// Used by HostedDownstreamStoreTests. The container runs an ordinary Foundry
+/// ChatClientAgent and reports back which conversation its own run left behind on the service,
+/// so the test can check whether a second copy of the turn was kept.
+///
+public sealed class DownstreamStoreHostedAgentFixture : HostedAgentFixture
+{
+ protected override string ScenarioName => "downstream-store";
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs
index 1725aa4831f..5b895d69643 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs
@@ -3,6 +3,7 @@
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
@@ -11,6 +12,7 @@
using Azure.AI.Projects.Agents;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
+using OpenAI.Responses;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
@@ -146,6 +148,83 @@ public async Task CountConversationItemsAsync(string conversationId)
return count;
}
+ ///
+ /// Reads every message stored in a conversation, oldest first, as a role and text pair. Used by
+ /// tests that need to see how many times a given turn was recorded, not just how many items there
+ /// are.
+ ///
+ public async Task> ReadConversationMessagesAsync(string conversationId)
+ {
+ List<(string Role, string Text)> messages = [];
+ await foreach (AgentResponseItem item in this.AgentOpenAIClient.GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc").ConfigureAwait(false))
+ {
+ if (item.AsResponseResultItem() is MessageResponseItem message)
+ {
+ var text = string.Concat(message.Content
+ .Where(c => c.Kind is ResponseContentPartKind.OutputText or ResponseContentPartKind.InputText)
+ .Select(c => c.Text));
+
+ messages.Add((message.Role.ToString(), text));
+ }
+ }
+
+ return messages;
+ }
+
+ ///
+ /// Reads the input a stored response was run with, oldest first, as a role and text pair. Along a
+ /// previous_response_id chain this is what the turn actually received, so tests can see
+ /// whether an earlier turn was handed to it more than once.
+ ///
+ public async Task> ReadResponseInputMessagesAsync(string responseId)
+ {
+ List<(string Role, string Text)> messages = [];
+ await foreach (ResponseItem item in this.AgentOpenAIClient.GetProjectResponsesClient().GetResponseInputItemsAsync(responseId).ConfigureAwait(false))
+ {
+ if (item is MessageResponseItem message)
+ {
+ var text = string.Concat(message.Content
+ .Where(c => c.Kind is ResponseContentPartKind.OutputText or ResponseContentPartKind.InputText)
+ .Select(c => c.Text));
+
+ messages.Add((message.Role.ToString(), text));
+ }
+ }
+
+ return messages;
+ }
+
+ ///
+ /// Tries to read a response back off the service by id, returning when
+ /// nothing is stored under it. Both the project-wide client and this scenario's per-agent client
+ /// are tried, because a response created inside the container is not necessarily reachable through
+ /// the same endpoint as one created for the caller.
+ ///
+ public async Task