diff --git a/sdks/dotnet/src/AGUI.Client/AGUIChatClient.cs b/sdks/dotnet/src/AGUI.Client/AGUIChatClient.cs index ada762a0ce..0c8dee19e8 100644 --- a/sdks/dotnet/src/AGUI.Client/AGUIChatClient.cs +++ b/sdks/dotnet/src/AGUI.Client/AGUIChatClient.cs @@ -319,6 +319,10 @@ private static RunAgentInput BuildRunAgentInput( Messages = messagesList.AsAGUIMessages().ToList(), }; + // Tracks whether the caller hand-supplied Resume via RawRepresentationFactory. + // When true, the approval/interrupt translations below yield to it entirely. + bool callerSuppliedResume = false; + if (providedInput is not null) { if (providedInput.Messages is { Count: > 0 }) @@ -340,6 +344,25 @@ private static RunAgentInput BuildRunAgentInput( { input.ParentRunId = providedInput.ParentRunId; } + + if (providedInput.Context is { Count: > 0 }) + { + input.Context = providedInput.Context; + } + + if (providedInput.ForwardedProperties.ValueKind != JsonValueKind.Undefined) + { + input.ForwardedProperties = providedInput.ForwardedProperties; + } + + // A caller-supplied Resume is authoritative: both the approval- and + // interrupt-response translations below yield to it (see the + // callerSuppliedResume guards), so the two resume paths stay symmetric. + if (providedInput.Resume is { Count: > 0 }) + { + input.Resume = providedInput.Resume; + callerSuppliedResume = true; + } } // Convert M.E.AI tools to AG-UI format @@ -348,69 +371,94 @@ private static RunAgentInput BuildRunAgentInput( input.Tools = options.Tools.AsAGUITools().ToList(); } - // Convert ToolApprovalResponseContent list (passed from AGUIChatClient) to resume payload - if (input.Resume is null && - options?.AdditionalProperties?.TryGetValue(AGUIClientInternalKeys.ApprovalResponses, out List? approvalResponses) is true - && approvalResponses is { Count: > 0 }) + List? approvalResponses = null; + List? interruptResponses = null; + options?.AdditionalProperties?.TryGetValue(AGUIClientInternalKeys.ApprovalResponses, out approvalResponses); + options?.AdditionalProperties?.TryGetValue(AGUIClientInternalKeys.InterruptResponses, out interruptResponses); + + if (callerSuppliedResume) { - var resumeList = new List(approvalResponses.Count); - foreach (var approvalResponse in approvalResponses) + // The caller's Resume wins, so approval/interrupt responses carried by the + // last message are not translated over it. Those responses were already + // stripped from the outgoing messages, so emit a diagnostic event to make + // the drop observable instead of silent. + int droppedApprovals = approvalResponses?.Count ?? 0; + int droppedInterrupts = interruptResponses?.Count ?? 0; + if (droppedApprovals > 0 || droppedInterrupts > 0) { - AGUIToolCallInfo? toolCallInfo = null; - if (approvalResponse.ToolCall is FunctionCallContent tc) + Activity.Current?.AddEvent(new ActivityEvent( + "agui.resume.caller_override_dropped_responses", + tags: new ActivityTagsCollection + { + { "agui.resume.dropped_approval_responses", droppedApprovals }, + { "agui.resume.dropped_interrupt_responses", droppedInterrupts }, + })); + } + } + else + { + // Convert ToolApprovalResponseContent list (passed from AGUIChatClient) to resume payload + if (approvalResponses is { Count: > 0 }) + { + var resumeList = new List(approvalResponses.Count); + foreach (var approvalResponse in approvalResponses) { - toolCallInfo = new AGUIToolCallInfo + AGUIToolCallInfo? toolCallInfo = null; + if (approvalResponse.ToolCall is FunctionCallContent tc) { - CallId = tc.CallId, - Name = tc.Name, - Arguments = tc.Arguments - }; - } + toolCallInfo = new AGUIToolCallInfo + { + CallId = tc.CallId, + Name = tc.Name, + Arguments = tc.Arguments + }; + } - // Check for a pre-computed result (from client-side tool execution) - string? toolResult = null; - if (approvalResponse.AdditionalProperties?.TryGetValue("result", out object? resultObj) is true) - { - toolResult = resultObj as string; + // Check for a pre-computed result (from client-side tool execution) + string? toolResult = null; + if (approvalResponse.AdditionalProperties?.TryGetValue("result", out object? resultObj) is true) + { + toolResult = resultObj as string; + } + + resumeList.Add(new AGUIResume + { + InterruptId = approvalResponse.RequestId, + Status = ResumeStatus.Resolved, + Payload = JsonSerializer.SerializeToElement( + new AGUIToolApprovalResumePayload + { + Approved = approvalResponse.Approved, + ToolCall = toolCallInfo, + Result = toolResult + }, + jsonSerializerOptions.GetTypeInfo(typeof(AGUIToolApprovalResumePayload))) + }); } - resumeList.Add(new AGUIResume - { - InterruptId = approvalResponse.RequestId, - Status = ResumeStatus.Resolved, - Payload = JsonSerializer.SerializeToElement( - new AGUIToolApprovalResumePayload - { - Approved = approvalResponse.Approved, - ToolCall = toolCallInfo, - Result = toolResult - }, - jsonSerializerOptions.GetTypeInfo(typeof(AGUIToolApprovalResumePayload))) - }); + input.Resume = resumeList; } - input.Resume = resumeList; - } - - // Convert InterruptResponseContent list to resume entries. - if (options?.AdditionalProperties?.TryGetValue(AGUIClientInternalKeys.InterruptResponses, out List? interruptResponses) is true - && interruptResponses is { Count: > 0 }) - { - var resumeList = input.Resume is { Count: > 0 } existing - ? new List(existing) - : new List(interruptResponses.Count); - - foreach (var ir in interruptResponses) + // Convert InterruptResponseContent list to resume entries, appending to any + // approval-derived entries produced above. + if (interruptResponses is { Count: > 0 }) { - resumeList.Add(new AGUIResume + var resumeList = input.Resume is { Count: > 0 } existing + ? new List(existing) + : new List(interruptResponses.Count); + + foreach (var ir in interruptResponses) { - InterruptId = ir.RequestId, - Status = ResumeStatus.Resolved, - Payload = ir.Payload, - }); - } + resumeList.Add(new AGUIResume + { + InterruptId = ir.RequestId, + Status = ResumeStatus.Resolved, + Payload = ir.Payload, + }); + } - input.Resume = resumeList; + input.Resume = resumeList; + } } return input; diff --git a/sdks/dotnet/tests/AGUI.Client.UnitTests/AGUIChatClientTest.cs b/sdks/dotnet/tests/AGUI.Client.UnitTests/AGUIChatClientTest.cs index 4d252e66c3..b0bb86d885 100644 --- a/sdks/dotnet/tests/AGUI.Client.UnitTests/AGUIChatClientTest.cs +++ b/sdks/dotnet/tests/AGUI.Client.UnitTests/AGUIChatClientTest.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using AGUI.Abstractions; @@ -143,6 +144,140 @@ public async Task GetStreamingResponse_SendsFullHistoryEveryTurn() Assert.Equal(3, transport.LastInput!.Messages.Count); } + // https://github.com/ag-ui-protocol/ag-ui/issues/2151 + // A caller-supplied RunAgentInput (via RawRepresentationFactory) must forward + // Context and ForwardedProperties onto the request actually sent, alongside + // the already-forwarded Messages/Tools/State/ParentRunId. + [Fact] + public async Task GetStreamingResponse_RawRepresentationFactory_ForwardsContextAndForwardedProperties() + { + var transport = new CapturingTransport(); + using var client = new AGUIChatClient(new() { Transport = transport }); + + var forwardedProperties = JsonDocument.Parse("{\"tenant\":\"acme\"}").RootElement.Clone(); + + var options = new ChatOptions + { + RawRepresentationFactory = _ => new RunAgentInput + { + Context = new List + { + new() { Description = "userId", Value = "u-123" }, + }, + ForwardedProperties = forwardedProperties, + }, + }; + + var history = new List { new(ChatRole.User, "Hello") }; + await DrainAsync(client.GetStreamingResponseAsync(history, options)); + + var sent = transport.LastInput!; + + Assert.NotNull(sent.Context); + var context = Assert.Single(sent.Context!); + Assert.Equal("userId", context.Description); + Assert.Equal("u-123", context.Value); + + Assert.Equal(JsonValueKind.Object, sent.ForwardedProperties.ValueKind); + Assert.Equal("acme", sent.ForwardedProperties.GetProperty("tenant").GetString()); + } + + // A caller-supplied Resume (via RawRepresentationFactory) must be forwarded too, + // like the other RunAgentInput fields (#2177 review follow-up). + [Fact] + public async Task GetStreamingResponse_RawRepresentationFactory_ForwardsResume() + { + var transport = new CapturingTransport(); + using var client = new AGUIChatClient(new() { Transport = transport }); + + var options = new ChatOptions + { + RawRepresentationFactory = _ => new RunAgentInput + { + Resume = new List + { + new() { InterruptId = "caller-interrupt", Status = ResumeStatus.Resolved }, + }, + }, + }; + + var history = new List { new(ChatRole.User, "Hello") }; + await DrainAsync(client.GetStreamingResponseAsync(history, options)); + + var resume = transport.LastInput!.Resume; + Assert.NotNull(resume); + var entry = Assert.Single(resume!); + Assert.Equal("caller-interrupt", entry.InterruptId); + } + + // A caller-supplied Resume takes precedence over the approval-response + // translation (the callerSuppliedResume guard yields to it) (#2177). + [Fact] + public async Task GetStreamingResponse_CallerResume_TakesPrecedenceOverApprovalResponses() + { + var transport = new CapturingTransport(); + using var client = new AGUIChatClient(new() { Transport = transport }); + + var options = new ChatOptions + { + RawRepresentationFactory = _ => new RunAgentInput + { + Resume = new List + { + new() { InterruptId = "caller-interrupt", Status = ResumeStatus.Resolved }, + }, + }, + }; + + var toolCall = new FunctionCallContent("call-1", "someTool", new Dictionary()); + var history = new List + { + new(ChatRole.User, [new ToolApprovalResponseContent("req-approval", approved: true, toolCall)]), + }; + + await DrainAsync(client.GetStreamingResponseAsync(history, options)); + + // The caller's Resume wins; the approval response is not translated over it. + var resume = transport.LastInput!.Resume; + Assert.NotNull(resume); + var entry = Assert.Single(resume!); + Assert.Equal("caller-interrupt", entry.InterruptId); + } + + // A caller-supplied Resume takes precedence over the interrupt-response translation + // too, matching the approval path. Previously the interrupt block appended + // unconditionally, so a caller Resume dropped approvals but kept interrupts (#2177). + [Fact] + public async Task GetStreamingResponse_CallerResume_TakesPrecedenceOverInterruptResponses() + { + var transport = new CapturingTransport(); + using var client = new AGUIChatClient(new() { Transport = transport }); + + var options = new ChatOptions + { + RawRepresentationFactory = _ => new RunAgentInput + { + Resume = new List + { + new() { InterruptId = "caller-interrupt", Status = ResumeStatus.Resolved }, + }, + }, + }; + + var history = new List + { + new(ChatRole.User, [new InterruptResponseContent("req-interrupt")]), + }; + + await DrainAsync(client.GetStreamingResponseAsync(history, options)); + + // The caller's Resume wins; the interrupt response is not appended over it. + var resume = transport.LastInput!.Resume; + Assert.NotNull(resume); + var entry = Assert.Single(resume!); + Assert.Equal("caller-interrupt", entry.InterruptId); + } + // https://github.com/microsoft/agent-framework/issues/5587 [Fact] public async Task AGUIChatClient_ToolCallResultWithPlainTextContent_DoesNotParseAsJson()