From 933267ac4dae1b6be0b78fac463e9c2bea56a11b Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Wed, 29 Jul 2026 18:09:09 -0700 Subject: [PATCH 1/3] Add regression tests and sample guidance for stable agent IDs in checkpointed workflows --- .../CheckpointAndRehydrate/Program.cs | 8 +- .../Orchestration/Handoff/AgentRegistry.cs | 87 ++++++---- .../WorkflowAgentCheckpointIdentityTests.cs | 153 ++++++++++++++++++ 3 files changed, 219 insertions(+), 29 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs 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..021de9cc881 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs @@ -0,0 +1,153 @@ +// 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().Contain(SpecialistReply, "the first turn should route triage -> specialist"); + + 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 reconstructed workflow resumes from the checkpoint and completes without a compatibility error. + secondResponse.Text.Should().Contain( + SpecialistReply, + "stable inner agent ids keep the executor identities compatible with the checkpoint across reconstruction"); + } + + [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."); + } + + /// + /// 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. + /// + private static AIAgent BuildWorkflowAgent(bool useStableInnerIds) + { + AIAgent triage = CreateTriageClient().AsAIAgent(new ChatClientAgentOptions + { + Id = useStableInnerIds ? "triage-agent" : null, + Name = TriageName, + 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, + 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; + + return new ChatResponse(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("handoff-call", handoffTool!)])); + }); + + // The specialist returns a fixed reply. + private static StatelessMockChatClient CreateSpecialistClient() => new((_, _) => + new ChatResponse(new ChatMessage(ChatRole.Assistant, SpecialistReply))); + + /// + /// 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() { } + } +} From 4b2f5cecf527738692ff8087c3f2616d7c4f7f42 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Wed, 29 Jul 2026 18:32:12 -0700 Subject: [PATCH 2/3] Updated tests to address PR comments --- .../WorkflowAgentCheckpointIdentityTests.cs | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs index 021de9cc881..d588747ee27 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs @@ -82,6 +82,32 @@ 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. @@ -90,12 +116,16 @@ await resumeAndRun.Should().ThrowAsync() /// When , each inner agent is assigned a deterministic . /// When , the id is left unset so each agent receives a random per-instance id. /// - private static AIAgent BuildWorkflowAgent(bool useStableInnerIds) + /// + /// 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, + Name = TriageName + nameSuffix, Description = "Routes the request to a specialist.", ChatOptions = new() { Instructions = "Always hand off to the specialist." }, }); @@ -103,7 +133,7 @@ private static AIAgent BuildWorkflowAgent(bool useStableInnerIds) AIAgent specialist = CreateSpecialistClient().AsAIAgent(new ChatClientAgentOptions { Id = useStableInnerIds ? "specialist-agent" : null, - Name = SpecialistName, + Name = SpecialistName + nameSuffix, Description = "Handles the request once triage hands off.", ChatOptions = new() { Instructions = "Answer the request." }, }); @@ -118,10 +148,11 @@ private static AIAgent BuildWorkflowAgent(bool useStableInnerIds) // 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; + 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!)])); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("handoff-call", handoffTool)])); }); // The specialist returns a fixed reply. From 0e3d7d50b50df34576e7b18c2e835459e1812462 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Wed, 29 Jul 2026 19:17:18 -0700 Subject: [PATCH 3/3] Improve test for checkpoint state. --- .../WorkflowAgentCheckpointIdentityTests.cs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs index d588747ee27..92cbbbeb333 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs @@ -37,7 +37,9 @@ public async Task WorkflowAgentSession_WithStableInnerAgentIds_ResumesAcrossReco // 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().Contain(SpecialistReply, "the first turn should route triage -> specialist"); + 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); @@ -48,10 +50,12 @@ public async Task WorkflowAgentSession_WithStableInnerAgentIds_ResumesAcrossReco AgentResponse secondResponse = await secondGeneration.RunAsync("Anything else?", resumedSession); - // Assert: the reconstructed workflow resumes from the checkpoint and completes without a compatibility error. - secondResponse.Text.Should().Contain( - SpecialistReply, - "stable inner agent ids keep the executor identities compatible with the checkpoint across reconstruction"); + // 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] @@ -155,9 +159,13 @@ private static AIAgent BuildWorkflowAgent(bool useStableInnerIds, string nameSuf return new ChatResponse(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("handoff-call", handoffTool)])); }); - // The specialist returns a fixed reply. - private static StatelessMockChatClient CreateSpecialistClient() => new((_, _) => - new ChatResponse(new ChatMessage(ChatRole.Assistant, SpecialistReply))); + // 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