Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 99 additions & 51 deletions sdks/dotnet/src/AGUI.Client/AGUIChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to add something here like :

Suggested change
}
}
if (providedInput.Resume is { Count: > 0 })
{
input.Resume = providedInput.Resume;
}

or is the Resume not relevant here?


// 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
Expand All @@ -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<ToolApprovalResponseContent>? approvalResponses) is true
&& approvalResponses is { Count: > 0 })
List<ToolApprovalResponseContent>? approvalResponses = null;
List<InterruptResponseContent>? interruptResponses = null;
options?.AdditionalProperties?.TryGetValue(AGUIClientInternalKeys.ApprovalResponses, out approvalResponses);
options?.AdditionalProperties?.TryGetValue(AGUIClientInternalKeys.InterruptResponses, out interruptResponses);

if (callerSuppliedResume)
{
var resumeList = new List<AGUIResume>(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<AGUIResume>(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<InterruptResponseContent>? interruptResponses) is true
&& interruptResponses is { Count: > 0 })
{
var resumeList = input.Resume is { Count: > 0 } existing
? new List<AGUIResume>(existing)
: new List<AGUIResume>(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<AGUIResume>(existing)
: new List<AGUIResume>(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;
Expand Down
135 changes: 135 additions & 0 deletions sdks/dotnet/tests/AGUI.Client.UnitTests/AGUIChatClientTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AGUIContext>
{
new() { Description = "userId", Value = "u-123" },
},
ForwardedProperties = forwardedProperties,
},
};

var history = new List<ChatMessage> { 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<AGUIResume>
{
new() { InterruptId = "caller-interrupt", Status = ResumeStatus.Resolved },
},
},
};

var history = new List<ChatMessage> { 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<AGUIResume>
{
new() { InterruptId = "caller-interrupt", Status = ResumeStatus.Resolved },
},
},
};

var toolCall = new FunctionCallContent("call-1", "someTool", new Dictionary<string, object?>());
var history = new List<ChatMessage>
{
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<AGUIResume>
{
new() { InterruptId = "caller-interrupt", Status = ResumeStatus.Resolved },
},
},
};

var history = new List<ChatMessage>
{
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()
Expand Down
Loading