// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.UnitTests; /// /// Repro tests for issue #5350: ToolApprovalRequestContent.ToolCall reportedly loses its /// concrete type after being persisted via a /// JsonCheckpointStore-backed CheckpointManager and restored on resume. /// /// These tests bypass the OP's SQL-backed store and HITL agent setup and directly exercise /// the same JSON pipeline used by the checkpoint path ( -> /// -> .DefaultOptions, /// which chains through AgentAbstractionsJsonUtilities -> AIJsonUtilities), at progressively /// more end-to-end layers up to a full CheckpointManager.CreateJson(...) + /// RunStreamingAsync / ResumeStreamingAsync cycle. /// /// At the time of writing all of these tests pass, which provides counter-evidence /// against the root-cause hypothesis stated in the issue body (missing polymorphism /// metadata / serializer-options chain). See /// docs/working/issue-5350-root-cause-validation-plan.md for the full investigation /// plan that builds on this baseline. /// public class ToolApprovalRequestCheckpointReproTests { private const string RequestId = "req-1"; private const string CallId = "call-1"; private const string FunctionName = "DoTheThing"; private static FunctionCallContent MakeFunctionCall() => new( callId: CallId, name: FunctionName, arguments: new Dictionary { ["x"] = 42 }); private static ToolApprovalRequestContent MakeApprovalRequest() => new(RequestId, MakeFunctionCall()); /// /// Direct round-trip of a through the same /// used by CheckpointManager.CreateJson(...). /// /// Per the issue, after deserialization the ToolCall property (declared as the /// abstract base ToolCallContent) is expected to remain a /// . If polymorphism is preserved, this test passes; /// if the discriminator is dropped on the wire or on read, ToolCall comes back /// as something other than and FICC's pattern match /// (tarc.ToolCall is FunctionCallContent { InformationalOnly: false }) silently /// skips the approval pair. /// [Fact] public void Repro_5350_ToolApprovalRequestContent_DirectJsonMarshallerRoundtrip_PreservesFunctionCallContent() { ToolApprovalRequestContent original = MakeApprovalRequest(); ToolApprovalRequestContent roundTripped = JsonSerializationTests.RunJsonRoundtrip(original); roundTripped.Should().NotBeNull(); roundTripped.RequestId.Should().Be(RequestId); roundTripped.ToolCall.Should().NotBeNull(); // This is the assertion that, per issue #5350, fails on resume. roundTripped.ToolCall.Should().BeOfType( "ToolApprovalRequestContent.ToolCall must round-trip as its concrete FunctionCallContent type, " + "otherwise FunctionInvokingChatClient.ExtractAndRemoveApprovalRequestsAndResponses will silently " + "skip the approval pair after a checkpoint resume (issue #5350)."); FunctionCallContent? fcc = roundTripped.ToolCall as FunctionCallContent; fcc!.CallId.Should().Be(CallId); fcc.Name.Should().Be(FunctionName); } /// /// Same round-trip as above, but wrapped in a . This more closely /// mirrors how the workflow runtime stores externally-visible request payloads in a checkpoint /// ( / ). The /// serializes the inner value with /// marshaller.Marshal(value.Value, value.Value.GetType()), so on the write side the /// runtime type is used (which should include the discriminator), and on the read side the /// inner value is materialized as a then re-deserialized as the /// declared type via . /// [Fact] public void Repro_5350_ToolApprovalRequestContent_WrappedInPortableValue_PreservesFunctionCallContent() { PortableValue original = new(MakeApprovalRequest()); PortableValue result = JsonSerializationTests.RunJsonRoundtrip(original); ToolApprovalRequestContent? extracted = result.As(); extracted.Should().NotBeNull(); extracted!.RequestId.Should().Be(RequestId); extracted.ToolCall.Should().NotBeNull(); extracted.ToolCall.Should().BeOfType( "PortableValue-wrapped ToolApprovalRequestContent must preserve the concrete " + "FunctionCallContent on the ToolCall property after checkpoint round-trip (issue #5350)."); } /// /// Round-trip the as the payload of an /// , which is the actual on-the-wire shape for HITL approval /// requests flowing out of a workflow. This is the closest serializer-only proxy for what /// happens when the issue reporter calls /// request.TryGetDataAs<ToolApprovalRequestContent>() after /// InProcessExecution.ResumeStreamingAsync(...). /// [Fact] public void Repro_5350_ToolApprovalRequestContent_AsExternalRequestData_PreservesFunctionCallContent() { RequestPort port = RequestPort.Create("Approval"); ExternalRequest original = ExternalRequest.Create(port, MakeApprovalRequest(), RequestId); ExternalRequest result = JsonSerializationTests.RunJsonRoundtrip(original); ToolApprovalRequestContent? extracted = result.Data.As(); extracted.Should().NotBeNull(); extracted!.ToolCall.Should().NotBeNull(); extracted.ToolCall.Should().BeOfType( "ExternalRequest.Data restored from a JSON checkpoint must preserve the concrete " + "FunctionCallContent on ToolApprovalRequestContent.ToolCall (issue #5350)."); } /// /// Stability check: run the direct round-trip many times in a row to demonstrate that /// the failure mode (if present) is deterministic and not a flaky/JIT-order artifact. /// [Fact] public void Repro_5350_DirectJsonMarshallerRoundtrip_IsDeterministic() { for (int i = 0; i < 25; i++) { ToolApprovalRequestContent original = MakeApprovalRequest(); ToolApprovalRequestContent roundTripped = JsonSerializationTests.RunJsonRoundtrip(original); roundTripped.ToolCall.Should().BeOfType( $"iteration {i}: ToolCall must consistently round-trip as FunctionCallContent"); } } /// /// End-to-end checkpoint -> resume repro using the actual CheckpointManager.CreateJson(...) /// path that the issue reporter uses. A trivial workflow whose entry point is a /// RequestPort<ToolApprovalRequestContent, ToolApprovalResponseContent> emits a /// pending external request containing a . We then: /// 1. checkpoint the run while the request is pending, /// 2. resume from the checkpoint via a fresh InProcessExecution, /// 3. read the re-emitted , and /// 4. assert that request.Data.As<ToolApprovalRequestContent>().ToolCall is /// still a . /// /// This is the closest serializer-and-runtime repro for issue #5350 that does not require a /// real ChatClientAgent + ApprovalRequiredAIFunction. /// [Fact] public async Task Repro_5350_EndToEnd_JsonCheckpointResume_PreservesFunctionCallContentAsync() { RequestPort requestPort = RequestPort.Create("ApprovalPort"); ForwardMessageExecutor processor = new("Processor"); Workflow workflow = new WorkflowBuilder(requestPort) .AddEdge(requestPort, processor) .Build(); CheckpointManager checkpointManager = CheckpointManager.CreateJson(new InMemoryJsonStore()); InProcessExecutionEnvironment env = InProcessExecution.OffThread; ToolApprovalRequestContent input = MakeApprovalRequest(); CheckpointInfo? checkpoint = null; ExternalRequest? originalPendingRequest = null; await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) .RunStreamingAsync(workflow, input)) { await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) { if (evt is RequestInfoEvent requestInfo) { originalPendingRequest ??= requestInfo.Request; } if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) { checkpoint = cp; } } } originalPendingRequest.Should().NotBeNull("the workflow should have emitted the approval request"); checkpoint.Should().NotBeNull("a checkpoint should have been produced while the request was pending"); // Sanity: the pre-checkpoint payload should be a FunctionCallContent. ToolApprovalRequestContent? preCheckpoint = originalPendingRequest!.Data.As(); preCheckpoint.Should().NotBeNull(); preCheckpoint!.ToolCall.Should().BeOfType( "the pre-checkpoint pending request payload must already be a FunctionCallContent"); // Resume from the checkpoint and capture the re-emitted request. await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) .ResumeStreamingAsync(workflow, checkpoint!); ExternalRequest? resumedPendingRequest = null; using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) { if (evt is RequestInfoEvent requestInfo) { resumedPendingRequest ??= requestInfo.Request; } } resumedPendingRequest.Should().NotBeNull("the resumed workflow should re-emit the pending request"); ToolApprovalRequestContent? postResume = resumedPendingRequest!.Data.As(); postResume.Should().NotBeNull( "ExternalRequest.Data.As() should materialize the payload after a JSON checkpoint resume"); // The assertion that the issue reporter says fails. postResume!.ToolCall.Should().NotBeNull(); postResume.ToolCall.Should().BeOfType( "after CheckpointManager.CreateJson round-trip via ResumeStreamingAsync, " + "ToolApprovalRequestContent.ToolCall must still be a FunctionCallContent so that " + "FunctionInvokingChatClient's pattern match (`tarc.ToolCall is FunctionCallContent`) continues to fire " + "(issue #5350)."); } /// /// Captures the raw JSON produced for a by the /// checkpoint marshaller. This test always passes; it exists to make the on-the-wire shape /// visible in test output / debugger when investigating issue #5350 (e.g. to confirm the /// "$type": "functionCall" discriminator is or is not present for the inner /// toolCall property). /// [Fact] public void Repro_5350_CaptureWireFormat_ForInspection() { JsonMarshaller marshaller = new(); JsonElement element = marshaller.Marshal(MakeApprovalRequest()); string serialized = element.GetRawText(); // Always-true assertion — purpose of this test is to expose the wire format. serialized.Should().NotBeNullOrEmpty(); serialized.Should().Contain(CallId, "the call id should be present in the serialized form"); } }