// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
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");
}
///
/// Maximal end-to-end repro for issue #5350 using the same shape as the OP's reported
/// scenario (pattern "B" in the GroupChatToolApproval sample), but with a single
/// bound directly into a
/// (no orchestration), and with a real -wrapped
/// tool that the agent actually attempts to call. The test:
///
/// - builds a over a that
/// returns a on the first turn and a final assistant
/// text on the second turn (so converts the FCC
/// to a and surfaces it as a workflow
/// ),
/// - persists checkpoints via the OP's exact path —
/// CheckpointManager.CreateJson(InMemoryJsonStore) +
/// InProcessExecutionEnvironment.WithCheckpointing(...).RunStreamingAsync(...) —
/// so every checkpoint is round-tripped through the same +
/// pipeline the OP's SQL-backed store uses,
/// - validates that the first-run carries a
/// whose ToolCall is a
/// ,
/// - disposes the run and resumes from the last
/// checkpoint via ,
/// - validates the re-emitted still carries a
/// whose ToolCall is a
/// — this is the assertion the OP claims fails,
/// - sends an approval response back into the resumed run and asserts the wrapped
/// function is actually invoked (counter increments) and the workflow completes with a
/// final assistant message.
///
///
[Fact]
public async Task Repro_5350_EndToEnd_ChatClientAgent_WithApprovalRequiredTool_JsonCheckpointResume_PreservesFunctionCallContentAndInvokesToolAsync()
{
// Arrange — counting tool wrapped for approval
int invocationCount = 0;
const string ToolName = "GetWeather";
const string ToolResultText = "Sunny, 22°C";
AIFunction underlyingTool = AIFunctionFactory.Create(
([Description("City to look up")] string city) =>
{
Interlocked.Increment(ref invocationCount);
return ToolResultText;
},
name: ToolName,
description: "Gets the weather for the given city");
ApprovalRequiredAIFunction approvalTool = new(underlyingTool);
// Arrange — mock chat client that turn-1 emits an FCC for the approval-required tool,
// turn-2 (after FunctionInvokingChatClient processes the approval + invokes the tool +
// appends a FunctionResultContent) emits a final assistant text.
const string ToolCallId = "call-1";
const string FinalAssistantText = "The weather in Amsterdam is sunny and 22°C.";
int chatCallIndex = 0;
List> capturedInputs = new();
MockChatClient mockChatClient = new((messages, options) =>
{
// Capture a snapshot of the inputs the agent passed in for this service call so the
// test can later assert the FunctionResultContent flowed back to the model.
capturedInputs.Add(new List(messages));
int index = Interlocked.Increment(ref chatCallIndex) - 1;
return index switch
{
0 => new ChatResponse(new ChatMessage(ChatRole.Assistant,
[new FunctionCallContent(
callId: ToolCallId,
name: ToolName,
arguments: new Dictionary { ["city"] = "Amsterdam" })])),
_ => new ChatResponse(new ChatMessage(ChatRole.Assistant, FinalAssistantText)),
};
});
ChatClientAgent agent = new(
mockChatClient,
instructions: "You are a weather agent.",
name: "WeatherAgent",
tools: [approvalTool]);
// Arrange — single-agent workflow. The AIAgent is auto-promoted to an ExecutorBinding
// via the implicit operator on ExecutorBinding.
Workflow workflow = new WorkflowBuilder(agent).Build();
// Arrange — JSON checkpoint manager backed by an in-memory JSON store. This mirrors
// the OP's "JsonCheckpointStore-backed CheckpointManager.CreateJson(...)" path —
// every checkpoint is round-tripped through the same JsonMarshaller +
// PortableValueConverter that the OP's SQL-backed store uses, just without the
// disk/SQL hop.
CheckpointManager checkpointManager = CheckpointManager.CreateJson(new InMemoryJsonStore());
InProcessExecutionEnvironment env = InProcessExecution.OffThread;
List inputMessages = [new(ChatRole.User, "What's the weather in Amsterdam?")];
// Act 1 — run until we see the approval request, then capture the latest checkpoint.
ExternalRequest? firstRunRequest = null;
CheckpointInfo? checkpoint = null;
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
.RunStreamingAsync(workflow, inputMessages))
{
// Trigger an actual turn — without a TurnToken the AIAgentHostExecutor will not
// invoke the agent. This matches the GroupChatToolApproval sample and the
// StreamAsyncWithTurnTokenShouldExecuteWorkflow pattern in InProcessExecutionTests.
(await firstRun.TrySendMessageAsync(new TurnToken(emitEvents: false)))
.Should().BeTrue("the workflow should accept a TurnToken");
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
if (evt is RequestInfoEvent requestInfo)
{
firstRunRequest ??= requestInfo.Request;
}
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
{
checkpoint = cp;
}
}
}
firstRunRequest.Should().NotBeNull(
"the ChatClientAgent + FICC pipeline should have surfaced the approval request as a workflow RequestInfoEvent");
checkpoint.Should().NotBeNull(
"a checkpoint should have been produced while the approval request was pending");
chatCallIndex.Should().Be(1, "the mock chat client should have been called exactly once before the approval was requested");
invocationCount.Should().Be(0, "the underlying tool must NOT have been invoked before the approval was granted");
ToolApprovalRequestContent? preCheckpoint = firstRunRequest!.Data.As();
preCheckpoint.Should().NotBeNull("the pending external request should carry a ToolApprovalRequestContent payload");
preCheckpoint!.ToolCall.Should().BeOfType(
"the pre-checkpoint pending request payload must already be a FunctionCallContent");
// Act 2 — resume from the checkpoint with a brand-new env / handle so that any
// in-process AIAgentHostExecutor instance state is gone and everything has to be
// rehydrated from the on-disk JSON.
ExternalRequest? resumedRequest = null;
List postResumeEvents = [];
await using (StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
.ResumeStreamingAsync(workflow, checkpoint!))
{
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
// First pass: see the re-emitted RequestInfoEvent, but don't block on it.
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
if (evt is RequestInfoEvent requestInfo)
{
resumedRequest ??= requestInfo.Request;
}
}
resumedRequest.Should().NotBeNull(
"the resumed workflow should re-emit the pending approval RequestInfoEvent");
// The core issue #5350 assertion.
ToolApprovalRequestContent? postResume = resumedRequest!.Data.As();
postResume.Should().NotBeNull(
"ExternalRequest.Data.As() should materialize the payload after a JSON-file checkpoint resume");
postResume!.ToolCall.Should().NotBeNull("the resumed TARC must carry its ToolCall");
postResume.ToolCall.Should().BeOfType(
"after CheckpointManager.CreateJson(InMemoryJsonStore) 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).");
FunctionCallContent resumedFcc = (FunctionCallContent)postResume.ToolCall;
resumedFcc.Name.Should().Be(ToolName);
resumedFcc.CallId.Should().EndWith(ToolCallId,
"the workflow rewrites the CallId with an executor-scoped prefix, but should preserve the original tail");
// Act 3 — send the approval response back into the resumed run and watch the
// remaining stream. This drives FunctionInvokingChatClient through its
// post-approval branch, where it must invoke the underlying AIFunction, append a
// FunctionResultContent, and call the model a second time.
ToolApprovalResponseContent approvalResponse = postResume.CreateResponse(approved: true);
await resumed.SendResponseAsync(resumedRequest.CreateResponse(approvalResponse));
using CancellationTokenSource cts2 = new(TimeSpan.FromSeconds(30));
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts2.Token))
{
postResumeEvents.Add(evt);
}
}
// Assert 3 — the tool actually got called as part of the approval round-trip, and
// the workflow continued without raising errors.
invocationCount.Should().Be(1,
"approving the request should cause FunctionInvokingChatClient to invoke the wrapped AIFunction exactly once");
chatCallIndex.Should().Be(2,
"after the tool was invoked, FunctionInvokingChatClient should have made a second chat-client call to produce the final assistant message");
capturedInputs.Should().HaveCount(2);
capturedInputs[1].Should().Contain(
m => m.Contents.OfType().Any(),
"the second chat-client call must include the FunctionResultContent produced by the approved tool invocation");
postResumeEvents.OfType().Should().BeEmpty(
"no workflow errors should be raised when responding to the resumed approval request");
postResumeEvents.OfType().Should().BeEmpty(
"no executor failures should be raised when responding to the resumed approval request");
}
///
/// Minimal stub for repro tests; delegates each call to a caller-supplied factory.
///
private sealed class MockChatClient(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)
{
ChatResponse response = await this.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
foreach (ChatResponseUpdate update in response.ToChatResponseUpdates())
{
yield return update;
}
}
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose() { }
}
}