.NET: [BREAKING] Rename from ServiceStoredSimulatingChatClient to PerServiceCallChatHistoryPersistingChatClient (#4993)

* Rename from ServiceStoredSimulatingChatClient to PerServiceCallChatHistoryPersistingChatClient

* Address PR comment
This commit is contained in:
westey
2026-03-31 18:32:05 +01:00
committed by GitHub
Unverified
parent 9c57680f00
commit 6e7254bba7
13 changed files with 198 additions and 151 deletions
@@ -42,7 +42,7 @@ The persistence timing and `FunctionResultContent` trimming behaviors are interr
## Considered Options
- Option 1: Per-run persistence with opt-in FRC (FunctionResultContent) trimming
- Option 2: Opt-in per-service-call persistence (via `SimulateServiceStoredChatHistory`)
- Option 2: Opt-in per-service-call persistence (via `RequirePerServiceCallChatHistoryPersistence`)
## Pros and Cons of the Options
@@ -57,12 +57,12 @@ Keep the current default behavior of persisting chat history only at the end of
- Bad, because if the process crashes mid-loop, all intermediate progress from the current run is lost, not satisfying driver C.
- Bad, because this option alone does not provide a way for users to opt into per-service-call persistence, not satisfying driver E.
### Option 2: Opt-in per-service-call persistence (via `SimulateServiceStoredChatHistory`)
### Option 2: Opt-in per-service-call persistence (via `RequirePerServiceCallChatHistoryPersistence`)
Introduce an optional SimulateServiceStoredChatHistory setting to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled).
Introduce an optional RequirePerServiceCallChatHistoryPersistence setting to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled).
Settings:
- `SimulateServiceStoredChatHistory` = `true`
- `RequirePerServiceCallChatHistoryPersistence` = `true`
- Good, because the stored history matches the service's behavior when opting in for both timing and content, fully satisfying driver A.
- Good, because intermediate progress is preserved if the process is interrupted, satisfying driver C.
@@ -73,36 +73,49 @@ Settings:
## Decision Outcome
Chosen option: **Option 2: Opt-in per-service-call persistence (via `SimulateServiceStoredChatHistory`)**. The existing per-run persistence behavior is retained as-is, requiring no changes from users. Per-service-call persistence is available as an opt-in feature via the `SimulateServiceStoredChatHistory` setting. This satisfies drivers B (atomicity) and D (simplicity) for the common case, while fully satisfying driver A (consistency) for users who opt into simulated service-stored behavior. Users who need per-service-call persistence for recoverability (driver C) can enable it explicitly.
Chosen option: **Option 2: Opt-in per-service-call persistence (via `RequirePerServiceCallChatHistoryPersistence`)**. The existing per-run persistence behavior is retained as-is, requiring no changes from users. Per-service-call persistence is available as an opt-in feature via the `RequirePerServiceCallChatHistoryPersistence` setting. This satisfies drivers B (atomicity) and D (simplicity) for the common case, while fully satisfying driver A (consistency) for users who opt into simulated service-stored behavior. Users who need per-service-call persistence for recoverability (driver C) can enable it explicitly.
### Configuration Matrix
The behavior depends on the combination of `UseProvidedChatClientAsIs` and `SimulateServiceStoredChatHistory`:
The behavior depends on the combination of `UseProvidedChatClientAsIs` and `RequirePerServiceCallChatHistoryPersistence`:
| `UseProvidedChatClientAsIs` | `SimulateServiceStoredChatHistory` | Behavior |
| `UseProvidedChatClientAsIs` | `RequirePerServiceCallChatHistoryPersistence` | Behavior |
|---|---|---|
| `false` (default) | `false` (default) | **Per-run persistence.** Messages are persisted at the end of the full agent run via the `ChatHistoryProvider`. |
| `false` | `true` | **Per-service-call persistence (simulated).** A `ServiceStoredSimulatingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. A sentinel `ConversationId` causes FIC to treat the conversation as service-managed. |
| `false` | `true` | **Per-service-call persistence (simulated).** A `PerServiceCallChatHistoryPersistingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. A sentinel `ConversationId` causes FIC to treat the conversation as service-managed. |
| `true` | `false` | **Per-run persistence.** No middleware is injected because the user has provided a custom chat client stack. Messages are persisted at the end of the run. |
| `true` | `true` | **User responsibility.** The system checks whether the custom chat client stack includes a `ServiceStoredSimulatingChatClient`. If not, a warning is emitted — the user is expected to have added their own per-service-call persistence mechanism. End-of-run persistence is skipped. |
| `true` | `true` | **User responsibility.** The system checks whether the custom chat client stack includes a `PerServiceCallChatHistoryPersistingChatClient`. If not, a warning is emitted — the user is expected to have added their own per-service-call persistence mechanism. End-of-run persistence is skipped. |
### Consequences
- Good, because per-run persistence is atomic by default — chat history is only updated when the full run succeeds, satisfying driver B.
- Good, because the default mental model is simple: one run = one history update, satisfying driver D.
- Good, because users who opt into `SimulateServiceStoredChatHistory` get stored history that matches the service's behavior for both timing and content, fully satisfying driver A.
- Good, because users who opt into `RequirePerServiceCallChatHistoryPersistence` get stored history that matches the service's behavior for both timing and content, fully satisfying driver A.
- Good, because per-service-call persistence preserves intermediate progress if the process is interrupted, satisfying driver C when opted in.
- Good, because no separate `FunctionResultContent` trimming logic is needed when per-service-call persistence is active — it is naturally handled.
- Good, because conflict detection (configurable via `ThrowOnChatHistoryProviderConflict`, `WarnOnChatHistoryProviderConflict`, `ClearOnChatHistoryProviderConflict`) prevents misconfiguration when a service returns a `ConversationId` alongside a configured `ChatHistoryProvider`.
- Bad, because per-service-call persistence (when opted in) may leave chat history in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), requiring manual recovery in rare cases.
- Neutral, because users who want per-service-call consistency can opt in via `SimulateServiceStoredChatHistory = true`, satisfying driver E.
- Neutral, because users who want per-service-call consistency can opt in via `RequirePerServiceCallChatHistoryPersistence = true`, satisfying driver E.
- Neutral, because increased write frequency from per-service-call persistence may impact performance for some storage backends; this can be mitigated with a caching decorator.
### Implementation Notes
#### Conversation ID Consistency
We should introduce a separate `ConversationIdPersistingChatClient`, middleware which allows us to
persist response `ConversationIds` during the FICC loop. This could be used with or without
`ServiceStoredSimulatingChatClient`.
When `RequirePerServiceCallChatHistoryPersistence` is enabled, the `PerServiceCallChatHistoryPersistingChatClient`
decorator also updates `session.ConversationId` after each service call. This handles two scenarios:
1. **Framework-managed chat history** — the decorator sets a sentinel `ConversationId` on the response
so that `FunctionInvokingChatClient` treats the conversation as service-managed (clearing accumulated
history between iterations and not injecting duplicate `FunctionCallContent` during approval processing).
2. **Service-stored chat history** — when the service returns a real `ConversationId`, the decorator
updates `session.ConversationId` immediately after each service call, rather than deferring the update
to the end of the run. This ensures intermediate ConversationId changes are captured even if the
process is interrupted mid-loop.
For some service-stored scenarios (e.g., the Conversations API with the Responses API), there is only
one thread with one ID, so every service call returns the same ConversationId and this per-call update
makes no practical difference. Enabling `RequirePerServiceCallChatHistoryPersistence` ensures consistent
per-service-call behavior across all service types regardless of how they manage ConversationIds.
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how the ChatClientAgent persists chat history after each individual
// call to the AI service, using the SimulateServiceStoredChatHistory option.
// call to the AI service, using the RequirePerServiceCallChatHistoryPersistence option.
// When an agent uses tools, FunctionInvokingChatClient may loop multiple times
// (service call → tool execution → service call), and intermediate messages (tool calls and
// results) are persisted after each service call. This allows you to inspect or recover them
@@ -9,7 +9,7 @@
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
//
// To use end-of-run persistence instead (atomic run semantics), remove the
// SimulateServiceStoredChatHistory = true setting (or set it to false). End-of-run
// RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run
// persistence is the default behavior.
//
// The sample runs two multi-turn conversations: one using non-streaming (RunAsync) and one
@@ -54,7 +54,7 @@ static string GetTime([Description("The city name.")] string city) =>
_ => $"{city}: time data not available."
};
// Create the agent — per-service-call persistence is enabled via SimulateServiceStoredChatHistory.
// Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence.
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
IChatClient chatClient = string.Equals(store, "TRUE", StringComparison.OrdinalIgnoreCase) ?
@@ -64,7 +64,7 @@ AIAgent agent = chatClient.AsAIAgent(
new ChatClientAgentOptions
{
Name = "WeatherAssistant",
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
ChatOptions = new()
{
Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
@@ -1,19 +1,19 @@
# In-Function-Loop Checkpointing
This sample demonstrates how `ChatClientAgent` can persist chat history after each individual call to the AI service using the `SimulateServiceStoredChatHistory` option. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop.
This sample demonstrates how `ChatClientAgent` can persist chat history after each individual call to the AI service using the `RequirePerServiceCallChatHistoryPersistence` option. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop.
## What This Sample Shows
When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By enabling `SimulateServiceStoredChatHistory = true`, chat history is persisted after each service call via the `ServiceStoredSimulatingChatClient` decorator:
When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By enabling `RequirePerServiceCallChatHistoryPersistence = true`, chat history is persisted after each service call via the `PerServiceCallChatHistoryPersistingChatClient` decorator:
- A `ServiceStoredSimulatingChatClient` decorator is inserted into the chat client pipeline
- A `PerServiceCallChatHistoryPersistingChatClient` decorator is inserted into the chat client pipeline
- Before each service call, the decorator loads history from the `ChatHistoryProvider` and prepends it to the request
- After each service call, the decorator notifies the `ChatHistoryProvider` (and any `AIContextProvider` instances) with the new messages
- Only **new** messages are sent to providers on each notification — messages that were already persisted in an earlier call within the same run are deduplicated automatically
By default (without `SimulateServiceStoredChatHistory`), chat history is persisted at the end of the full agent run instead. To use per-service-call persistence, set `SimulateServiceStoredChatHistory = true` on `ChatClientAgentOptions`.
By default (without `RequirePerServiceCallChatHistoryPersistence`), chat history is persisted at the end of the full agent run instead. To use per-service-call persistence, set `RequirePerServiceCallChatHistoryPersistence = true` on `ChatClientAgentOptions`.
With `SimulateServiceStoredChatHistory` = true, the behavior matches that of chat history stored in the underlying AI service exactly.
With `RequirePerServiceCallChatHistoryPersistence` = true, the behavior matches that of chat history stored in the underlying AI service exactly.
Per-service-call persistence is useful for:
- **Crash recovery** — if the process is interrupted mid-loop, the intermediate tool calls and results are already persisted
@@ -29,7 +29,7 @@ The sample asks the agent about the weather and time in three cities. The model
```
ChatClientAgent
└─ FunctionInvokingChatClient (handles tool call loop)
└─ ServiceStoredSimulatingChatClient (persists after each service call)
└─ PerServiceCallChatHistoryPersistingChatClient (persists after each service call)
└─ Leaf IChatClient (Azure OpenAI)
```
@@ -139,8 +139,8 @@ public sealed partial class ChatClientAgent : AIAgent
this._logger = (loggerFactory ?? chatClient.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
// Warn if using a custom chat client stack with simulated service stored persistence but no ServiceStoredSimulatingChatClient.
this.WarnOnMissingServiceStoredSimulatingClient();
// Warn if using a custom chat client stack with simulated service stored persistence but no PerServiceCallChatHistoryPersistingChatClient.
this.WarnOnMissingPerServiceCallChatHistoryPersistingChatClient();
}
/// <summary>
@@ -454,7 +454,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// Notifies the <see cref="ChatHistoryProvider"/> and all <see cref="AIContextProviders"/> of successfully completed messages.
/// </summary>
/// <remarks>
/// This method is also called by <see cref="ServiceStoredSimulatingChatClient"/> to persist messages per-service-call.
/// This method is also called by <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to persist messages per-service-call.
/// </remarks>
internal async Task NotifyProvidersOfNewMessagesAsync(
ChatClientAgentSession session,
@@ -486,7 +486,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// Notifies the <see cref="ChatHistoryProvider"/> and all <see cref="AIContextProviders"/> of a failure during a service call.
/// </summary>
/// <remarks>
/// This method is also called by <see cref="ServiceStoredSimulatingChatClient"/> to report failures per-service-call.
/// This method is also called by <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to report failures per-service-call.
/// </remarks>
internal async Task NotifyProvidersOfFailureAsync(
ChatClientAgentSession session,
@@ -701,7 +701,7 @@ public sealed partial class ChatClientAgent : AIAgent
throw new InvalidOperationException("A session must be provided when continuing a background response with a continuation token.");
}
if ((continuationToken is not null || chatOptions?.AllowBackgroundResponses is true) && this.SimulatesServiceStoredChatHistory && this._logger.IsEnabled(LogLevel.Warning))
if ((continuationToken is not null || chatOptions?.AllowBackgroundResponses is true) && this.RequiresPerServiceCallChatHistoryPersistence && this._logger.IsEnabled(LogLevel.Warning))
{
var warningAgentName = this.GetLoggingAgentName();
this._logger.LogAgentChatClientBackgroundResponseFallback(this.Id, warningAgentName);
@@ -740,10 +740,10 @@ public sealed partial class ChatClientAgent : AIAgent
IEnumerable<ChatMessage> inputMessagesForChatClient = inputMessages;
// Populate the session messages only if we are not continuing an existing response as it's not allowed.
// When SimulateServiceStoredChatHistory is active, the ServiceStoredSimulatingChatClient
// When RequirePerServiceCallChatHistoryPersistence is active, the PerServiceCallChatHistoryPersistingChatClient
// owns the chat history lifecycle — it loads history before each service call. The agent
// must not load history itself, as that would result in duplicate messages.
if (chatOptions?.ContinuationToken is null && !this.SimulatesServiceStoredChatHistory)
if (chatOptions?.ContinuationToken is null && !this.RequiresPerServiceCallChatHistoryPersistence)
{
// Add any existing messages from the session to the messages to be sent to the chat client.
// The ChatHistoryProvider returns the merged result (history + input messages).
@@ -837,14 +837,14 @@ public sealed partial class ChatClientAgent : AIAgent
/// Updates the session conversation ID at the end of an agent run.
/// </summary>
/// <remarks>
/// When a <see cref="ServiceStoredSimulatingChatClient"/> handles per-service-call
/// When a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> handles per-service-call
/// conversation ID updates, this end-of-run update is skipped. When the decorator is
/// absent, the update is performed here. When <paramref name="forceUpdate"/> is <see langword="true"/>
/// (continuation token scenarios), the update is always performed.
/// </remarks>
private void UpdateSessionConversationIdAtEndOfRun(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken, bool forceUpdate = false)
{
if (!forceUpdate && this.SimulatesServiceStoredChatHistory)
if (!forceUpdate && this.RequiresPerServiceCallChatHistoryPersistence)
{
return;
}
@@ -856,7 +856,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// Notifies providers of successfully completed messages at the end of an agent run.
/// </summary>
/// <remarks>
/// When a <see cref="ServiceStoredSimulatingChatClient"/> handles per-service-call
/// When a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> handles per-service-call
/// notification, this end-of-run notification is skipped. When no decorator is present,
/// all messages are persisted.
/// When <paramref name="forceNotify"/> is <see langword="true"/> (continuation token or
@@ -871,7 +871,7 @@ public sealed partial class ChatClientAgent : AIAgent
CancellationToken cancellationToken,
bool forceNotify = false)
{
if (!forceNotify && this.SimulatesServiceStoredChatHistory)
if (!forceNotify && this.RequiresPerServiceCallChatHistoryPersistence)
{
return Task.CompletedTask;
}
@@ -883,7 +883,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// Notifies providers of a failure at the end of an agent run.
/// </summary>
/// <remarks>
/// When a <see cref="ServiceStoredSimulatingChatClient"/> handles per-service-call
/// When a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> handles per-service-call
/// notification (including failure), this end-of-run notification is skipped to avoid
/// duplicate notification. In all other cases, failure is reported at the end of the run.
/// </remarks>
@@ -894,7 +894,7 @@ public sealed partial class ChatClientAgent : AIAgent
ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
if (this.SimulatesServiceStoredChatHistory)
if (this.RequiresPerServiceCallChatHistoryPersistence)
{
return Task.CompletedTask;
}
@@ -905,14 +905,14 @@ public sealed partial class ChatClientAgent : AIAgent
/// <summary>
/// Gets a value indicating whether the agent is configured to simulate service-stored chat history.
/// When <see langword="true"/>, end-of-run persistence and history loading are skipped because a
/// per-service-call decorator (such as <see cref="ServiceStoredSimulatingChatClient"/> or a
/// per-service-call decorator (such as <see cref="PerServiceCallChatHistoryPersistingChatClient"/> or a
/// user-supplied equivalent) is expected to handle the history lifecycle.
/// </summary>
private bool SimulatesServiceStoredChatHistory
private bool RequiresPerServiceCallChatHistoryPersistence
{
get
{
return this._agentOptions?.SimulateServiceStoredChatHistory is true;
return this._agentOptions?.RequirePerServiceCallChatHistoryPersistence is true;
}
}
@@ -923,7 +923,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// The base class sets <see cref="AIAgent.CurrentRunContext"/> with the raw session parameter
/// (which may be null) and restores it after each yield in streaming scenarios. After
/// <see cref="PrepareSessionAndMessagesAsync"/> resolves or creates a session, we update the
/// context so the <see cref="ServiceStoredSimulatingChatClient"/> decorator always has a valid session.
/// context so the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> decorator always has a valid session.
/// The original agent from the context is preserved to maintain the top-of-stack agent in
/// decorated agent scenarios.
/// </remarks>
@@ -939,19 +939,19 @@ public sealed partial class ChatClientAgent : AIAgent
/// <summary>
/// Checks for potential misconfiguration when using a custom chat client stack and logs warnings.
/// </summary>
private void WarnOnMissingServiceStoredSimulatingClient()
private void WarnOnMissingPerServiceCallChatHistoryPersistingChatClient()
{
if (this._agentOptions?.UseProvidedChatClientAsIs is not true)
{
return;
}
if (this._agentOptions?.SimulateServiceStoredChatHistory is not true)
if (this._agentOptions?.RequirePerServiceCallChatHistoryPersistence is not true)
{
return;
}
var persistingClient = this.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
var persistingClient = this.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
if (persistingClient is null && this._logger.IsEnabled(LogLevel.Warning))
{
var loggingAgentName = this.GetLoggingAgentName();
@@ -998,7 +998,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// </summary>
/// <remarks>
/// This method is used by both the agent (during <see cref="PrepareSessionAndMessagesAsync"/>) and by
/// <see cref="ServiceStoredSimulatingChatClient"/> to load history before each service call.
/// <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to load history before each service call.
/// </remarks>
internal async Task<IEnumerable<ChatMessage>> LoadChatHistoryAsync(
ChatClientAgentSession session,
@@ -72,12 +72,12 @@ internal static partial class ChatClientAgentLogMessages
/// <summary>
/// Logs a warning when <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>
/// and <see cref="ChatClientAgentOptions.SimulateServiceStoredChatHistory"/> is <see langword="true"/>,
/// but no <see cref="ServiceStoredSimulatingChatClient"/> is found in the custom chat client stack.
/// and <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>,
/// but no <see cref="PerServiceCallChatHistoryPersistingChatClient"/> is found in the custom chat client stack.
/// </summary>
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Agent {AgentId}/{AgentName}: SimulateServiceStoredChatHistory is enabled with a custom chat client stack (UseProvidedChatClientAsIs), but no ServiceStoredSimulatingChatClient was found in the pipeline. Chat history will not be persisted by ChatClientAgent. Consider adding a ServiceStoredSimulatingChatClient to the pipeline using the UseServiceStoredChatHistorySimulation extension method if you have not added your own persistence mechanism.")]
Message = "Agent {AgentId}/{AgentName}: RequirePerServiceCallChatHistoryPersistence is enabled with a custom chat client stack (UseProvidedChatClientAsIs), but no PerServiceCallChatHistoryPersistingChatClient was found in the pipeline. Chat history will not be persisted by ChatClientAgent. Consider adding a PerServiceCallChatHistoryPersistingChatClient to the pipeline using the UsePerServiceCallChatHistoryPersistence extension method if you have not added your own persistence mechanism.")]
public static partial void LogAgentChatClientMissingPersistingClient(
this ILogger logger,
string agentId,
@@ -92,7 +92,7 @@ internal static partial class ChatClientAgentLogMessages
/// </summary>
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Agent {AgentId}/{AgentName}: SimulateServiceStoredChatHistory is enabled but we have to fall back to end-of-run persistence because the run involves background responses.")]
Message = "Agent {AgentId}/{AgentName}: RequirePerServiceCallChatHistoryPersistence is enabled but we have to fall back to end-of-run persistence because the run involves background responses.")]
public static partial void LogAgentChatClientBackgroundResponseFallback(
this ILogger logger,
string agentId,
@@ -92,38 +92,56 @@ public sealed class ChatClientAgentOptions
public bool ThrowOnChatHistoryProviderConflict { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether the <see cref="ChatClientAgent"/> should simulate
/// service-stored chat history behavior using its configured <see cref="ChatHistoryProvider"/>.
/// Gets or sets a value indicating whether the <see cref="ChatClientAgent"/> should persist
/// chat history after each individual service call within the <see cref="FunctionInvokingChatClient"/>
/// loop, rather than at the end of the full agent run.
/// </summary>
/// <remarks>
/// <para>
/// When set to <see langword="true"/>, a <see cref="ServiceStoredSimulatingChatClient"/> decorator is
/// injected between the <see cref="FunctionInvokingChatClient"/> and the leaf <see cref="IChatClient"/>
/// in the chat client pipeline. This decorator takes full ownership of the chat history lifecycle:
/// it loads history from the <see cref="ChatHistoryProvider"/> before each service call and persists
/// new messages after each service call. It also returns a sentinel <see cref="ChatOptions.ConversationId"/>
/// on the response, causing the <see cref="FunctionInvokingChatClient"/> to treat the conversation
/// as service-managed — clearing accumulated history and not injecting duplicate
/// <see cref="FunctionCallContent"/> during approval-response processing.
/// </para>
/// <para>
/// This mode aligns the behavior of framework-managed chat history with service-stored chat history,
/// ensuring consistency in how messages are stored and loaded, including during function calling loops
/// and tool-call termination scenarios.
/// When set to <see langword="true"/>, a <see cref="PerServiceCallChatHistoryPersistingChatClient"/>
/// decorator becomes active in the chat client pipeline. It handles two complementary scenarios:
/// </para>
/// <list type="bullet">
/// <item>
/// <term>Framework-managed chat history</term>
/// <description>
/// The decorator loads history from the <see cref="ChatHistoryProvider"/> before each service call
/// and persists new request and response messages after each call. It returns a sentinel
/// <see cref="ChatOptions.ConversationId"/> on the response, causing the
/// <see cref="FunctionInvokingChatClient"/> to treat the conversation as service-managed — clearing
/// accumulated history between iterations and not injecting duplicate <see cref="FunctionCallContent"/>
/// during approval-response processing.
/// </description>
/// </item>
/// <item>
/// <term>AI Service-stored chat history</term>
/// <description>
/// When the service manages its own chat history (returning a real <see cref="ChatOptions.ConversationId"/>),
/// the decorator updates <see cref="ChatClientAgentSession.ConversationId"/> after each service call so
/// that intermediate ConversationId changes are captured immediately. For some services (e.g., the
/// Conversations API with the Responses API), there is only one thread with one ID, so every service
/// call updates it anyway and updating the <see cref="ChatClientAgentSession.ConversationId"/> has little effect
/// since it's the same ID. For other services (e.g., Responses API with Response IDs), a new ID is generated
/// with each service call, so updating the <see cref="ChatClientAgentSession.ConversationId"/> ensures that the
/// latest ID is always captured, even mid-run.
/// Enabling this option ensures consistent per-service-call behavior across all service types.
/// </description>
/// </item>
/// </list>
/// <para>
/// When set to <see langword="false"/> (the default), the <see cref="ChatClientAgent"/> handles
/// chat history persistence at the end of the full agent run via the <see cref="ChatHistoryProvider"/>
/// pipeline.
/// chat history persistence at the end of the full agent run via the <see cref="ChatHistoryProvider"/> if using
/// framework-managed chat history. For AI service-stored chat history, the <see cref="ChatClientAgentSession.ConversationId"/>
/// updates happen only at the end of the run.
/// </para>
/// <para>
/// When setting the <see cref="UseProvidedChatClientAsIs"/> setting to <see langword="true"/> and
/// <see cref="SimulateServiceStoredChatHistory"/> to <see langword="true"/>, ensure that your custom chat client stack includes a
/// <see cref="ServiceStoredSimulatingChatClient"/> to enable per-service-call persistence.
/// If no <see cref="ServiceStoredSimulatingChatClient"/> is provided, and you are not storing chat history via other means,
/// <see cref="RequirePerServiceCallChatHistoryPersistence"/> to <see langword="true"/>, ensure that your custom chat client stack includes a
/// <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to enable per-service-call persistence.
/// If no <see cref="PerServiceCallChatHistoryPersistingChatClient"/> is provided, and you are not storing chat history via other means,
/// no chat history may be stored.
/// When using a custom chat client stack, you can add a <see cref="ServiceStoredSimulatingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation"/>
/// When using a custom chat client stack, you can add a <see cref="PerServiceCallChatHistoryPersistingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UsePerServiceCallChatHistoryPersistence"/>
/// extension method.
/// </para>
/// </remarks>
@@ -131,7 +149,7 @@ public sealed class ChatClientAgentOptions
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool SimulateServiceStoredChatHistory { get; set; }
public bool RequirePerServiceCallChatHistoryPersistence { get; set; }
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
@@ -149,6 +167,6 @@ public sealed class ChatClientAgentOptions
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
SimulateServiceStoredChatHistory = this.SimulateServiceStoredChatHistory,
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
};
}
@@ -86,21 +86,21 @@ public static class ChatClientBuilderExtensions
services: services);
/// <summary>
/// Adds a <see cref="ServiceStoredSimulatingChatClient"/> to the chat client pipeline.
/// Adds a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator should be positioned between the <see cref="FunctionInvokingChatClient"/> and the leaf
/// <see cref="IChatClient"/> in the pipeline. It simulates service-stored chat history behavior by
/// loading history before each service call, persisting after each call, and returning a sentinel
/// <see cref="ChatOptions.ConversationId"/> on the response.
/// <see cref="IChatClient"/> in the pipeline. It persists chat history after each individual service call
/// and updates the session <see cref="ChatOptions.ConversationId"/> per call for both framework-managed
/// and service-stored chat history scenarios.
/// </para>
/// <para>
/// This extension method is intended for use with custom chat client stacks when
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
/// the <see cref="ChatClientAgent"/> automatically injects this decorator when
/// <see cref="ChatClientAgentOptions.SimulateServiceStoredChatHistory"/> is <see langword="true"/>.
/// the <see cref="ChatClientAgent"/> automatically includes this decorator in the pipeline and activates it when
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>.
/// </para>
/// <para>
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> and will throw an
@@ -110,8 +110,8 @@ public static class ChatClientBuilderExtensions
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static ChatClientBuilder UseServiceStoredChatHistorySimulation(this ChatClientBuilder builder)
public static ChatClientBuilder UsePerServiceCallChatHistoryPersistence(this ChatClientBuilder builder)
{
return builder.Use(innerClient => new ServiceStoredSimulatingChatClient(innerClient));
return builder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
}
}
@@ -63,16 +63,16 @@ public static class ChatClientExtensions
});
}
// ServiceStoredSimulatingChatClient is only injected when SimulateServiceStoredChatHistory is enabled.
// PerServiceCallChatHistoryPersistingChatClient is only injected when RequirePerServiceCallChatHistoryPersistence is enabled.
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the leaf client.
// ChatClientBuilder.Build applies factories in reverse order, making the first Use() call outermost.
// By adding our decorator second, the resulting pipeline is:
// FunctionInvokingChatClient → ServiceStoredSimulatingChatClient → leaf IChatClient
// FunctionInvokingChatClient → PerServiceCallChatHistoryPersistingChatClient → leaf IChatClient
// This allows the decorator to simulate service-stored chat history by loading history before
// each service call, persisting after each call, and returning a sentinel ConversationId.
if (options?.SimulateServiceStoredChatHistory is true)
if (options?.RequirePerServiceCallChatHistoryPersistence is true)
{
chatBuilder.Use(innerClient => new ServiceStoredSimulatingChatClient(innerClient));
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
}
var agentChatClient = chatBuilder.Build(services);
@@ -11,23 +11,39 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating chat client that simulates service-stored chat history behavior using
/// framework-managed <see cref="ChatHistoryProvider"/> instances.
/// A delegating chat client that persists chat history and updates session state after each
/// individual service call within the <see cref="FunctionInvokingChatClient"/> loop.
/// </summary>
/// <remarks>
/// <para>
/// This decorator is intended to operate between the <see cref="FunctionInvokingChatClient"/> and the leaf
/// <see cref="IChatClient"/> in a <see cref="ChatClientAgent"/> pipeline.
/// <see cref="IChatClient"/> in a <see cref="ChatClientAgent"/> pipeline. It is activated when
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>.
/// </para>
/// <para>
/// Before each service call, it loads chat history from the agent's <see cref="ChatHistoryProvider"/>
/// and prepends it to the request messages. After each successful service call, it persists
/// new request and response messages to the provider. It also returns a sentinel
/// <see cref="ChatOptions.ConversationId"/> on the response so that the
/// <see cref="FunctionInvokingChatClient"/> treats the conversation as service-managed —
/// clearing accumulated history between iterations and not injecting duplicate
/// <see cref="FunctionCallContent"/> during approval-response processing.
/// When active, it handles two complementary scenarios:
/// </para>
/// <list type="bullet">
/// <item>
/// <term>Framework-managed chat history</term>
/// <description>
/// Before each service call, the decorator loads history from the agent's <see cref="ChatHistoryProvider"/>
/// and prepends it to the request messages. After each successful call, it persists new messages to
/// the provider and returns a sentinel <see cref="ChatOptions.ConversationId"/> so that
/// <see cref="FunctionInvokingChatClient"/> treats the conversation as service-managed — clearing
/// accumulated history between iterations and not injecting duplicate <see cref="FunctionCallContent"/>
/// during approval-response processing.
/// </description>
/// </item>
/// <item>
/// <term>Service-stored chat history</term>
/// <description>
/// When the underlying service manages its own chat history (real <see cref="ChatOptions.ConversationId"/>),
/// the decorator updates <see cref="ChatClientAgentSession.ConversationId"/> after each service call so
/// that intermediate ConversationId changes are captured immediately rather than only at the end of the run.
/// </description>
/// </item>
/// </list>
/// <para>
/// This chat client must be used within the context of a running <see cref="ChatClientAgent"/>. It retrieves the
/// current agent and session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's
@@ -38,7 +54,7 @@ namespace Microsoft.Agents.AI;
/// available or if the agent is not a <see cref="ChatClientAgent"/>.
/// </para>
/// </remarks>
internal sealed class ServiceStoredSimulatingChatClient : DelegatingChatClient
internal sealed class PerServiceCallChatHistoryPersistingChatClient : DelegatingChatClient
{
/// <summary>
/// A sentinel value returned on <see cref="ChatResponse.ConversationId"/> to signal
@@ -59,10 +75,10 @@ internal sealed class ServiceStoredSimulatingChatClient : DelegatingChatClient
internal const string LocalHistoryConversationId = "_agent_local_chat_history";
/// <summary>
/// Initializes a new instance of the <see cref="ServiceStoredSimulatingChatClient"/> class.
/// Initializes a new instance of the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
public ServiceStoredSimulatingChatClient(IChatClient innerClient)
public PerServiceCallChatHistoryPersistingChatClient(IChatClient innerClient)
: base(innerClient)
{
}
@@ -237,18 +253,18 @@ internal sealed class ServiceStoredSimulatingChatClient : DelegatingChatClient
{
var runContext = AIAgent.CurrentRunContext
?? throw new InvalidOperationException(
$"{nameof(ServiceStoredSimulatingChatClient)} can only be used within the context of a running AIAgent. " +
$"{nameof(PerServiceCallChatHistoryPersistingChatClient)} can only be used within the context of a running AIAgent. " +
"Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
var chatClientAgent = runContext.Agent.GetService<ChatClientAgent>()
?? throw new InvalidOperationException(
$"{nameof(ServiceStoredSimulatingChatClient)} can only be used with a {nameof(ChatClientAgent)}. " +
$"{nameof(PerServiceCallChatHistoryPersistingChatClient)} can only be used with a {nameof(ChatClientAgent)}. " +
$"The current agent is of type '{runContext.Agent.GetType().Name}'.");
if (runContext.Session is not ChatClientAgentSession chatClientAgentSession)
{
throw new InvalidOperationException(
$"{nameof(ServiceStoredSimulatingChatClient)} requires a {nameof(ChatClientAgentSession)}. " +
$"{nameof(PerServiceCallChatHistoryPersistingChatClient)} requires a {nameof(ChatClientAgentSession)}. " +
$"The current session is of type '{runContext.Session?.GetType().Name ?? "null"}'.");
}
@@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Shared test helper for <see cref="ChatClientAgent"/> integration tests that verify
/// end-to-end behavior with <see cref="ServiceStoredSimulatingChatClient"/> and
/// end-to-end behavior with <see cref="PerServiceCallChatHistoryPersistingChatClient"/> and
/// <see cref="FunctionInvokingChatClient"/>.
/// </summary>
internal static class ChatClientAgentTestHelper
@@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests that verify the end-to-end approval flow behavior of the
/// <see cref="ChatClientAgent"/> class with <see cref="ServiceStoredSimulatingChatClient"/>,
/// <see cref="ChatClientAgent"/> class with <see cref="PerServiceCallChatHistoryPersistingChatClient"/>,
/// ensuring that chat history is correctly persisted across multi-turn approval interactions.
/// </summary>
public class ChatClientAgent_ApprovalsTests
@@ -48,7 +48,7 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -260,7 +260,7 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -520,7 +520,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
},
expectedServiceCallCount: 1,
expectedHistory:
@@ -554,7 +554,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Tools = [tool] },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
},
expectedServiceCallCount: 2,
expectedHistory:
@@ -13,15 +13,15 @@ using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for the <see cref="ServiceStoredSimulatingChatClient"/> decorator,
/// Contains unit tests for the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> decorator,
/// verifying that it persists messages via the <see cref="ChatHistoryProvider"/> after each
/// individual service call by default, or marks messages for end-of-run persistence when the
/// <see cref="ChatClientAgentOptions.SimulateServiceStoredChatHistory"/> option is enabled.
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> option is enabled.
/// </summary>
public class ServiceStoredSimulatingChatClientTests
public class PerServiceCallChatHistoryPersistingChatClientTests
{
/// <summary>
/// Verifies that by default (SimulateServiceStoredChatHistory is false),
/// Verifies that by default (RequirePerServiceCallChatHistoryPersistence is false),
/// the ChatHistoryProvider receives messages after a successful non-streaming call.
/// </summary>
[Fact]
@@ -50,7 +50,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -97,7 +97,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -145,7 +145,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -163,7 +163,7 @@ public class ServiceStoredSimulatingChatClientTests
}
/// <summary>
/// Verifies that the decorator is NOT injected by default (SimulateServiceStoredChatHistory is false).
/// Verifies that the decorator is NOT injected by default (RequirePerServiceCallChatHistoryPersistence is false).
/// </summary>
[Fact]
public void ChatClient_DoesNotContainDecorator_ByDefault()
@@ -175,15 +175,15 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new());
// Assert
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
Assert.Null(decorator);
}
/// <summary>
/// Verifies that the decorator is injected when SimulateServiceStoredChatHistory is true.
/// Verifies that the decorator is injected when RequirePerServiceCallChatHistoryPersistence is true.
/// </summary>
[Fact]
public void ChatClient_ContainsDecorator_WhenSimulateServiceStoredChatHistory()
public void ChatClient_ContainsDecorator_WhenRequirePerServiceCallChatHistoryPersistence()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -191,11 +191,11 @@ public class ServiceStoredSimulatingChatClientTests
// Act
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Assert
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
Assert.NotNull(decorator);
}
@@ -215,27 +215,27 @@ public class ServiceStoredSimulatingChatClientTests
});
// Assert
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
Assert.Null(decorator);
}
/// <summary>
/// Verifies that the SimulateServiceStoredChatHistory option is included in Clone().
/// Verifies that the RequirePerServiceCallChatHistoryPersistence option is included in Clone().
/// </summary>
[Fact]
public void ChatClientAgentOptions_Clone_IncludesSimulateServiceStoredChatHistory()
public void ChatClientAgentOptions_Clone_IncludesRequirePerServiceCallChatHistoryPersistence()
{
// Arrange
var options = new ChatClientAgentOptions
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
};
// Act
var cloned = options.Clone();
// Assert
Assert.True(cloned.SimulateServiceStoredChatHistory);
Assert.True(cloned.RequirePerServiceCallChatHistoryPersistence);
}
/// <summary>
@@ -289,7 +289,7 @@ public class ServiceStoredSimulatingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -358,7 +358,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -407,7 +407,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
AIContextProviders = [mockContextProvider.Object],
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -454,7 +454,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
AIContextProviders = [mockContextProvider.Object],
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -513,7 +513,7 @@ public class ServiceStoredSimulatingChatClientTests
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
AIContextProviders = [mockContextProvider.Object],
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -587,7 +587,7 @@ public class ServiceStoredSimulatingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -652,7 +652,7 @@ public class ServiceStoredSimulatingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -720,8 +720,8 @@ public class ServiceStoredSimulatingChatClientTests
/// <summary>
/// Verifies that when per-service-call persistence is active and no real conversation ID exists,
/// <see cref="ChatClientAgent"/> sets the <see cref="ServiceStoredSimulatingChatClient.LocalHistoryConversationId"/>
/// sentinel on the chat options and <see cref="ServiceStoredSimulatingChatClient"/> strips it before
/// <see cref="ChatClientAgent"/> sets the <see cref="PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId"/>
/// sentinel on the chat options and <see cref="PerServiceCallChatHistoryPersistingChatClient"/> strips it before
/// forwarding to the inner client.
/// </summary>
[Fact]
@@ -741,7 +741,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -773,7 +773,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -808,7 +808,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Create a session with a real conversation ID.
@@ -842,7 +842,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -862,7 +862,7 @@ public class ServiceStoredSimulatingChatClientTests
/// skip provider resolution in the agent (the decorator handles it).
/// </summary>
[Fact]
public async Task RunAsync_SetsSentinelOnSession_WhenSimulateServiceStoredChatHistoryActiveAsync()
public async Task RunAsync_SetsSentinelOnSession_WhenRequirePerServiceCallChatHistoryPersistenceActiveAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -875,7 +875,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -883,7 +883,7 @@ public class ServiceStoredSimulatingChatClientTests
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — session should have the sentinel conversation ID
Assert.Equal(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
Assert.Equal(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
/// <summary>
@@ -924,7 +924,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act & Assert — conflict detection should throw
@@ -969,7 +969,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1025,7 +1025,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1077,7 +1077,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1137,7 +1137,7 @@ public class ServiceStoredSimulatingChatClientTests
// No ChatHistoryProvider — so conflict detection won't throw.
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1192,7 +1192,7 @@ public class ServiceStoredSimulatingChatClientTests
// No ChatHistoryProvider — so conflict detection won't throw.
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1253,7 +1253,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -1270,7 +1270,7 @@ public class ServiceStoredSimulatingChatClientTests
Assert.Equal("test", messageList[0].Text);
// Assert — session should NOT have the sentinel (agent handles ConversationId at end-of-run)
Assert.NotEqual(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
/// <summary>
@@ -1291,7 +1291,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -1309,6 +1309,6 @@ public class ServiceStoredSimulatingChatClientTests
Assert.NotEmpty(updates);
// Assert — session should NOT have the sentinel
Assert.NotEqual(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
}