diff --git a/dotnet/samples/03-workflows/Checkpoint/CheckpointAndRehydrate/Program.cs b/dotnet/samples/03-workflows/Checkpoint/CheckpointAndRehydrate/Program.cs
index d8d88aefcbe..652a6a2f65a 100644
--- a/dotnet/samples/03-workflows/Checkpoint/CheckpointAndRehydrate/Program.cs
+++ b/dotnet/samples/03-workflows/Checkpoint/CheckpointAndRehydrate/Program.cs
@@ -81,7 +81,12 @@ private static async Task Main()
}
Console.WriteLine($"Number of checkpoints created: {checkpoints.Count}");
- // Rehydrate a new workflow instance from a saved checkpoint and continue execution
+ //
+ // A rehydrated workflow must preserve the topology and executor identities of the workflow that
+ // created the checkpoint. This executor-only workflow rebuilds identically because its executors
+ // use fixed ids. Agent-based workflows must recreate each local agent with the same
+ // ChatClientAgentOptions.Id (and, if set, the same Name), otherwise the executor ids no longer
+ // match the checkpoint and resume fails.
var newWorkflow = WorkflowFactory.BuildWorkflow();
const int CheckpointIndex = 5;
Console.WriteLine($"\n\nHydrating a new workflow instance from the {CheckpointIndex + 1}th checkpoint.");
@@ -89,6 +94,7 @@ private static async Task Main()
await using StreamingRun newCheckpointedRun =
await InProcessExecution.ResumeStreamingAsync(newWorkflow, savedCheckpoint, checkpointManager);
+ //
await foreach (WorkflowEvent evt in newCheckpointedRun.WatchStreamAsync())
{
diff --git a/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs b/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs
index 3a21dd8d288..2bb1e0acd02 100644
--- a/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs
+++ b/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs
@@ -10,50 +10,81 @@
/// The to use as the agent backend.
internal sealed class AgentRegistry(IChatClient chatClient)
{
+ //
+ // Give each agent a stable, unique Id so its workflow executor identity stays the same when the
+ // workflow is reconstructed (for example per request or dependency-injection scope), which keeps
+ // checkpoints resumable. If an agent also has a Name, keep that stable too, since the executor
+ // identity includes it. Use a fixed logical role here, not a conversation, request, or user id.
internal const string IntakeAgentName = "Assistant";
- public AIAgent IntakeAgent { get; } = chatClient.AsAIAgent(
- instructions:
- """
+ public AIAgent IntakeAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
+ {
+ Id = "intake-agent",
+ Name = IntakeAgentName,
+ ChatOptions = new()
+ {
+ Instructions =
+ """
You receive a user request and are responsible for routing to the correct initial expert agent.
""",
- IntakeAgentName
- );
+ },
+ });
+ //
internal const string LiquidityAnalysisAgentName = "Liquidity Analysis";
- public AIAgent LiquidityAnalysisAgent { get; } = chatClient.AsAIAgent(
- instructions:
- """
+ public AIAgent LiquidityAnalysisAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
+ {
+ Id = "liquidity-analysis-agent",
+ Name = LiquidityAnalysisAgentName,
+ ChatOptions = new()
+ {
+ Instructions =
+ """
You are responsible for Liquidity Analysis.
""",
- LiquidityAnalysisAgentName
- );
+ },
+ });
internal const string TaxAnalysisAgentName = "Tax Analysis";
- public AIAgent TaxAnalysisAgent { get; } = chatClient.AsAIAgent(
- instructions:
- """
- You are responsible for Tax Analysis.
+ public AIAgent TaxAnalysisAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
+ {
+ Id = "tax-analysis-agent",
+ Name = TaxAnalysisAgentName,
+ ChatOptions = new()
+ {
+ Instructions =
+ """
+ You are responsible for Tax Analysis.
""",
- TaxAnalysisAgentName
- );
+ },
+ });
internal const string ForeignExchangeAgentName = "Foreign Exchange Analysis";
- public AIAgent ForeignExchangeAgent { get; } = chatClient.AsAIAgent(
- instructions:
- """
- You are responsible for Foreign Exchange Analysis.
+ public AIAgent ForeignExchangeAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
+ {
+ Id = "foreign-exchange-agent",
+ Name = ForeignExchangeAgentName,
+ ChatOptions = new()
+ {
+ Instructions =
+ """
+ You are responsible for Foreign Exchange Analysis.
""",
- ForeignExchangeAgentName
- );
+ },
+ });
internal const string EquityAgentName = "Equity Analysis";
- public AIAgent EquityAgent { get; } = chatClient.AsAIAgent(
- instructions:
- """
- You are responsible for Equity Analysis.
+ public AIAgent EquityAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
+ {
+ Id = "equity-analysis-agent",
+ Name = EquityAgentName,
+ ChatOptions = new()
+ {
+ Instructions =
+ """
+ You are responsible for Equity Analysis.
""",
- EquityAgentName
- );
+ },
+ });
public IEnumerable Experts => [this.LiquidityAnalysisAgent, this.TaxAnalysisAgent, this.ForeignExchangeAgent, this.EquityAgent];
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs
new file mode 100644
index 00000000000..92cbbbeb333
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs
@@ -0,0 +1,192 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Workflows.UnitTests;
+
+///
+/// Verifies that a workflow hosted as an resumes a serialized session after its inner
+/// agents are reconstructed only when each inner agent keeps a stable executor identity. A stable
+/// is sufficient; if an agent also sets a
+/// , that name must stay stable because the executor id includes it.
+///
+public class WorkflowAgentCheckpointIdentityTests
+{
+ private const string TriageName = "triage_agent";
+ private const string SpecialistName = "specialist_agent";
+ private const string SpecialistReply = "SPECIALIST_REPLY";
+
+ // Held constant across reconstruction so resume differences come only from the inner agent identities.
+ private const string OuterWorkflowAgentId = "workflow-agent";
+
+ [Fact]
+ public async Task WorkflowAgentSession_WithStableInnerAgentIds_ResumesAcrossReconstructionAsync()
+ {
+ // Arrange: build a first-generation workflow agent whose inner agents have stable, explicit ids.
+ AIAgent firstGeneration = BuildWorkflowAgent(useStableInnerIds: true);
+ AgentSession session = await firstGeneration.CreateSessionAsync();
+
+ // Act: complete a first turn (triage hands off to the specialist), then serialize the session.
+ AgentResponse firstResponse = await firstGeneration.RunAsync("Please help me.", session);
+ firstResponse.Text.Should().Be(
+ $"{SpecialistReply}:turn:1",
+ "the first turn should route triage -> specialist, and the specialist observes a single user turn");
+
+ JsonElement serialized = await firstGeneration.SerializeSessionAsync(session);
+
+ // Reconstruct a completely fresh object graph (new clients, agents, and workflow) using the same stable ids,
+ // modeling a second dependency-injection scope.
+ AIAgent secondGeneration = BuildWorkflowAgent(useStableInnerIds: true);
+ AgentSession resumedSession = await secondGeneration.DeserializeSessionAsync(serialized);
+
+ AgentResponse secondResponse = await secondGeneration.RunAsync("Anything else?", resumedSession);
+
+ // Assert: the specialist observes both user turns, which is only possible if the checkpointed conversation was
+ // restored. A fresh (non-resumed) session would restart the count at turn:1, so this distinguishes a genuine
+ // resume from a compatible-but-empty restart.
+ secondResponse.Text.Should().Be(
+ $"{SpecialistReply}:turn:2",
+ "stable inner agent ids keep the executor identities compatible and the reconstructed workflow resumes from the checkpoint");
+ }
+
+ [Fact]
+ public async Task WorkflowAgentSession_WithoutStableInnerAgentIds_FailsAcrossReconstructionAsync()
+ {
+ // Arrange: build a first-generation workflow agent whose inner agents receive random ids (no explicit id).
+ AIAgent firstGeneration = BuildWorkflowAgent(useStableInnerIds: false);
+ AgentSession session = await firstGeneration.CreateSessionAsync();
+
+ // Complete a first turn and serialize the session; a completed handoff turn captures a checkpoint.
+ AgentResponse firstResponse = await firstGeneration.RunAsync("Please help me.", session);
+ firstResponse.Text.Should().Contain(SpecialistReply, "the first turn should route triage -> specialist");
+
+ JsonElement serialized = await firstGeneration.SerializeSessionAsync(session);
+
+ // Reconstruct with new random inner ids but the SAME outer workflow-agent id, proving that a stable outer id
+ // alone does not stabilize the inner executor identities.
+ AIAgent secondGeneration = BuildWorkflowAgent(useStableInnerIds: false);
+
+ // Act: deserialization itself succeeds; the incompatibility surfaces only when the resuming run validates the
+ // checkpoint against the reconstructed workflow.
+ AgentSession resumedSession = await secondGeneration.DeserializeSessionAsync(serialized);
+
+ Func resumeAndRun = () => secondGeneration.RunAsync("Anything else?", resumedSession);
+
+ // Assert: the second run throws because the reconstructed executor ids no longer match the checkpoint.
+ await resumeAndRun.Should().ThrowAsync()
+ .WithMessage("The specified checkpoint is not compatible with the workflow associated with this runner.");
+ }
+
+ [Fact]
+ public async Task WorkflowAgentSession_WithStableIdsButChangedInnerNames_FailsAcrossReconstructionAsync()
+ {
+ // Arrange: first generation uses stable ids and the default inner names.
+ AIAgent firstGeneration = BuildWorkflowAgent(useStableInnerIds: true);
+ AgentSession session = await firstGeneration.CreateSessionAsync();
+
+ AgentResponse firstResponse = await firstGeneration.RunAsync("Please help me.", session);
+ firstResponse.Text.Should().Contain(SpecialistReply, "the first turn should route triage -> specialist");
+
+ JsonElement serialized = await firstGeneration.SerializeSessionAsync(session);
+
+ // Reconstruct with the SAME stable ids but different inner names. Because the executor id is derived from
+ // both the name and the id, changing only the name still breaks checkpoint compatibility.
+ AIAgent secondGeneration = BuildWorkflowAgent(useStableInnerIds: true, nameSuffix: "-renamed");
+
+ // Act: deserialization succeeds; the incompatibility surfaces on the resuming run.
+ AgentSession resumedSession = await secondGeneration.DeserializeSessionAsync(serialized);
+
+ Func resumeAndRun = () => secondGeneration.RunAsync("Anything else?", resumedSession);
+
+ // Assert: changing a set name invalidates the executor identity even though the id is stable.
+ await resumeAndRun.Should().ThrowAsync()
+ .WithMessage("The specified checkpoint is not compatible with the workflow associated with this runner.");
+ }
+
+ ///
+ /// Builds a fresh Handoff workflow-as-agent object graph. Every call constructs new chat clients, agents, and a
+ /// new workflow, modeling reconstruction across dependency-injection scopes.
+ ///
+ ///
+ /// When , each inner agent is assigned a deterministic .
+ /// When , the id is left unset so each agent receives a random per-instance id.
+ ///
+ ///
+ /// Optional suffix appended to each inner agent's . Used to simulate a
+ /// reconstruction that keeps ids stable but changes names.
+ ///
+ private static AIAgent BuildWorkflowAgent(bool useStableInnerIds, string nameSuffix = "")
+ {
+ AIAgent triage = CreateTriageClient().AsAIAgent(new ChatClientAgentOptions
+ {
+ Id = useStableInnerIds ? "triage-agent" : null,
+ Name = TriageName + nameSuffix,
+ Description = "Routes the request to a specialist.",
+ ChatOptions = new() { Instructions = "Always hand off to the specialist." },
+ });
+
+ AIAgent specialist = CreateSpecialistClient().AsAIAgent(new ChatClientAgentOptions
+ {
+ Id = useStableInnerIds ? "specialist-agent" : null,
+ Name = SpecialistName + nameSuffix,
+ Description = "Handles the request once triage hands off.",
+ ChatOptions = new() { Instructions = "Answer the request." },
+ });
+
+ Workflow workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triage)
+ .WithHandoff(triage, specialist)
+ .Build();
+
+ return workflow.AsAIAgent(id: OuterWorkflowAgentId, name: OuterWorkflowAgentId);
+ }
+
+ // Triage hands off to the specialist by calling the handoff tool present on the request.
+ private static StatelessMockChatClient CreateTriageClient() => new((messages, options) =>
+ {
+ string handoffTool = options?.Tools?
+ .FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name
+ ?? throw new InvalidOperationException("Expected a handoff tool to be available to the triage agent.");
+
+ return new ChatResponse(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("handoff-call", handoffTool)]));
+ });
+
+ // The specialist echoes how many user turns it has observed. Because a genuine resume restores the prior turn
+ // from the checkpoint, the count advances across turns, distinguishing a real resume from a fresh restart.
+ private static StatelessMockChatClient CreateSpecialistClient() => new((messages, _) =>
+ {
+ int observedUserTurns = messages.Count(m => m.Role == ChatRole.User);
+ return new ChatResponse(new ChatMessage(ChatRole.Assistant, $"{SpecialistReply}:turn:{observedUserTurns}"));
+ });
+
+ ///
+ /// A minimal whose response is a pure function of the request, so reconstructing it
+ /// preserves behavior.
+ ///
+ private sealed class StatelessMockChatClient(Func, ChatOptions?, ChatResponse> responseFactory) : IChatClient
+ {
+ public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
+ Task.FromResult(responseFactory(messages, options));
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ foreach (var update in (await this.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)).ToChatResponseUpdates())
+ {
+ yield return update;
+ }
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null) => null;
+
+ public void Dispose() { }
+ }
+}