mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
cdd80c61ac
* Fix dangling function_call on approval response in Foundry hosting (#5662) Make the wire<->AF approval translation in Microsoft.Agents.AI.Foundry.Hosting lossless so the resume turn pairs function_call/function_call_output correctly. Root cause: InputConverter.ConvertMcpApprovalResponse rebuilt FunctionCallContent with CallId set to the FICC-composed AF request id (ficc_<callId>) and Name hardcoded to 'mcp_approval'. This (a) broke Azure Conversations pairing because the persisted function_call had CallId <callId> without prefix, and (b) made FICC unable to invoke the original tool by name on resume. Fix: ToolApprovalIdMap now records the original FunctionCallContent (CallId, Name, Arguments) keyed by wire id at outbound time. InputConverter reconstructs the original FCC on inbound, falling back to the legacy placeholder when no mapping exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Suppress orphan function_call items at the wire (#5662) Foundry-Hosting's OutputConverter was emitting FunctionCallContent as wire `function_call` items while dropping the paired FunctionResultContent. The result: every auto-invoked tool call left an orphan `function_call` in the response store. The next turn (chained via previous_response_id or via a workflow that yields after one turn under externalLoop) reloaded that history and submitted it to Azure Conversations, which rejected it with HTTP 400 `No tool output found for function call ...`. Function call/result pairs are entirely internal to the agent's tool-calling loop and have no place on the wire. Approval-required calls already surface separately via ToolApprovalRequestContent → mcp_approval_request, so dropping FCC is safe. FCC's message-close behavior is preserved so pre-tool text doesn't accidentally concatenate with post-tool text under the same MessageId. Existing OutputConverter tests asserting FCC wire emission are updated to assert suppression. Verified end-to-end against the declarative-workflow-menu external_loop bench: three-turn previous_response_id chain (menu → carbonara price → EXIT) now completes, where it previously failed at turn 2 with HTTP 400. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fail fast when no approval mapping is recorded (#5662) The previous best-effort placeholder fallback in InputConverter.ConvertMcpApprovalResponse couldn't actually round-trip — it just delayed and obscured the failure as an HTTP 400 deep inside the agent loop. Throw InvalidOperationException with the wire id and a clear cause hint instead so the failure is local and actionable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Trim narrative comments and exception message (#5662) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Defer FunctionCallContent emission until matched FunctionResultContent (#5662) Replace blanket FCC suppression with deferred emission. FunctionCallContent is buffered (name + serialized arguments) keyed by CallId; the function_call and function_call_output wire items are only flushed once the matching FunctionResultContent arrives. - Auto-invoked FCC/FRC pairs surface as paired wire items so Azure's stored conversation has matched call+output and previous_response_id resume works (closes the orphan-function_call symptom from #5662). - Orphan FCCs (e.g. workflow paused at a checkpoint mid-tool-loop) are dropped so they never poison the response store. - Approval flows are unchanged: TARC still emits mcp_approval_request and the post-approval FRC has no buffered FCC to pair with so it is dropped; the approval round-trip handles its own pairing via mcp_approval_*. - Leaves the door open for future client-side function calling: that pattern would surface an FCC without an FRC, would need to opt out of buffering, but the wire shape is already correct. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Emit FunctionCallContent and FunctionResultContent directly (option B) Replace the deferred-emission/buffer-and-drop strategy with direct emission of both function_call and function_call_output wire items. Rationale: a lone FunctionCallContent in OutputConverter's input can mean two semantically different things, and only the caller knows which: - Auto-invoke (FICC response surface): always paired with a matching FRC; both halves should appear on the wire as historical record. - HITL / port-pause request (typed RequestPort<FunctionCallContent,...> or workflow synthesizing a request): a lone FCC IS the wire signal that the caller must resume by supplying a function_call_output. Buffering+dropping orphans silently swallows the second case. Emitting both directly is the only correct shape for OpenAI Responses semantics. The InputConverter already accepts function_call_output and mcp_approval_response on resume, so the round-trip works for both kinds. The approval-flow round-trip fixes (ToolApprovalIdMap rich ApprovalEntry, fail-fast on missing mapping in ConvertMcpApprovalResponse) remain intact. Tests: updated 7 OutputConverter tests + 1 OutputConverterWorkflow test that asserted the old buffer/drop semantics; all 227 tests pass. Refs #5662 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #5668 review feedback on TryLoadMap Stop swallowing JsonException in ToolApprovalIdMap.TryLoadMap. The catch block recovered to an empty map and a stale comment claimed the caller would gracefully degrade via a 'wire-id fallback path' — but that path no longer exists: InputConverter.ConvertMcpApprovalResponse fails fast when no entry is found. Letting the JsonException propagate produces an error message that points at the actual cause (a state-bag format incompatibility), instead of converting it into a confusing 'no approval mapping recorded' InvalidOperationException one stack frame later. Refs #5662, PR #5668 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #5668 review feedback round 2 - OutputConverter FRC: emit string results as raw text (no JSON-quoting), matching the wire contract for function_call_output.output. - OutputConverter FCC: validate non-empty CallId before closing the in-flight text message, so a skipped FCC no longer breaks output-item boundaries. - ToolApprovalIdMap.Record: take pre-serialized arguments JSON (string) and primitive callId/name. Drops [RequiresUnreferencedCode]/[RequiresDynamicCode] so trim/AOT warnings stop propagating to call sites. - ToolApprovalIdMap.Record: no-op when callId or name is empty. - Tests: dedup duplicate ConvertItemsToMessages_McpApprovalResponse no-mapping test; add coverage for empty-CallId boundary, raw-string FRC payload, and Record empty-key no-op. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
214 lines
11 KiB
C#
214 lines
11 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Azure.AI.AgentServer.Responses;
|
|
using Azure.AI.AgentServer.Responses.Models;
|
|
using Microsoft.Agents.AI.Workflows;
|
|
using Microsoft.Extensions.AI;
|
|
using Moq;
|
|
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
|
|
|
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="OutputConverter"/> driven directly by hand-crafted update
|
|
/// sequences that mirror the patterns produced by real workflow executions
|
|
/// (sequential, group chat, code executor, sub-workflow, mixed content types).
|
|
/// </summary>
|
|
public class OutputConverterWorkflowTests
|
|
{
|
|
[Fact]
|
|
public async Task SequentialWorkflowPattern_ProducesCorrectEventsAsync()
|
|
{
|
|
// Simulate what WorkflowSession produces for a 2-agent sequential workflow
|
|
var (stream, _) = CreateTestStream();
|
|
var updates = new[]
|
|
{
|
|
// Superstep 1: Agent 1
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
|
|
new AgentResponseUpdate { MessageId = "msg_a1", Contents = [new MeaiTextContent("Agent 1 output")] },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
|
// Superstep 2: Agent 2
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") },
|
|
new AgentResponseUpdate { MessageId = "msg_a2", Contents = [new MeaiTextContent("Agent 2 output")] },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) },
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
|
};
|
|
|
|
var events = new List<ResponseStreamEvent>();
|
|
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
|
{
|
|
events.Add(evt);
|
|
}
|
|
|
|
// 4 workflow action items + 2 text messages = 6 output items
|
|
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
|
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
|
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GroupChatPattern_ProducesCorrectEventsAsync()
|
|
{
|
|
// Simulate round-robin group chat: agent1 → agent2 → agent1 → terminate
|
|
var (stream, _) = CreateTestStream();
|
|
var updates = new[]
|
|
{
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
|
|
new AgentResponseUpdate { MessageId = "msg_gc_1", Contents = [new MeaiTextContent("Agent 1 turn 1")] },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_2", "turn") },
|
|
new AgentResponseUpdate { MessageId = "msg_gc_2", Contents = [new MeaiTextContent("Agent 2 turn 1")] },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_2", null) },
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(3) },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
|
|
new AgentResponseUpdate { MessageId = "msg_gc_3", Contents = [new MeaiTextContent("Agent 1 turn 2")] },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(3) },
|
|
};
|
|
|
|
var events = new List<ResponseStreamEvent>();
|
|
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
|
{
|
|
events.Add(evt);
|
|
}
|
|
|
|
// 6 workflow actions + 3 text messages = 9 output items
|
|
Assert.Equal(9, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
|
Assert.Equal(3, events.OfType<ResponseTextDeltaEvent>().Count());
|
|
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CodeExecutorPattern_ProducesCorrectEventsAsync()
|
|
{
|
|
// Simulate a code-based FunctionExecutor: invoked → completed, no text content
|
|
// (code executors don't produce AgentResponseUpdateEvent, just executor lifecycle)
|
|
var (stream, _) = CreateTestStream();
|
|
var updates = new[]
|
|
{
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("uppercase_fn", "hello") },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("uppercase_fn", "HELLO") },
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
|
// Second executor uses the output
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("format_agent", "start") },
|
|
new AgentResponseUpdate { MessageId = "msg_fmt", Contents = [new MeaiTextContent("Formatted: HELLO")] },
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("format_agent", null) },
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
|
};
|
|
|
|
var events = new List<ResponseStreamEvent>();
|
|
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
|
{
|
|
events.Add(evt);
|
|
}
|
|
|
|
// 4 workflow actions + 1 text message = 5 output items
|
|
Assert.Equal(5, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
|
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
|
|
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubworkflowPattern_ProducesCorrectEventsAsync()
|
|
{
|
|
// Simulate a parent workflow that invokes a sub-workflow executor
|
|
var (stream, _) = CreateTestStream();
|
|
var updates = new[]
|
|
{
|
|
new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("parent") },
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
|
// Sub-workflow executor invoked
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("sub_workflow_host", "start") },
|
|
// Inner agent within sub-workflow produces text (unwrapped by WorkflowSession)
|
|
new AgentResponseUpdate { MessageId = "msg_sub_1", Contents = [new MeaiTextContent("Sub-workflow agent output")] },
|
|
// Sub-workflow executor completed
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("sub_workflow_host", null) },
|
|
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
|
};
|
|
|
|
var events = new List<ResponseStreamEvent>();
|
|
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
|
{
|
|
events.Add(evt);
|
|
}
|
|
|
|
// 2 workflow actions + 1 text message = 3 output items
|
|
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
|
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
|
|
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WorkflowWithMultipleContentTypes_HandlesAllCorrectlyAsync()
|
|
{
|
|
// Simulate a workflow producing reasoning, text, function calls, and usage
|
|
var (stream, _) = CreateTestStream();
|
|
var updates = new[]
|
|
{
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("planner", "start") },
|
|
// Reasoning
|
|
new AgentResponseUpdate { Contents = [new TextReasoningContent("Let me think about this...")] },
|
|
// Function call (tool use)
|
|
new AgentResponseUpdate
|
|
{
|
|
Contents = [new FunctionCallContent("call_search", "web_search",
|
|
new Dictionary<string, object?> { ["query"] = "latest news" })]
|
|
},
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("planner", null) },
|
|
// Next executor uses tool result
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("writer", "start") },
|
|
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("Based on my research, ")] },
|
|
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("here are the findings.")] },
|
|
new AgentResponseUpdate
|
|
{
|
|
Contents = [new UsageContent(new UsageDetails { InputTokenCount = 500, OutputTokenCount = 200, TotalTokenCount = 700 })]
|
|
},
|
|
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("writer", null) },
|
|
};
|
|
|
|
var events = new List<ResponseStreamEvent>();
|
|
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
|
{
|
|
events.Add(evt);
|
|
}
|
|
|
|
// Workflow actions: 4 (2 invoked + 2 completed)
|
|
// Content: 1 reasoning + 1 function_call (lone FCC = HITL request) + 1 text = 3
|
|
// Total: 7 output items
|
|
Assert.Equal(7, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
|
Assert.Single(events.OfType<ResponseFunctionCallArgumentsDoneEvent>());
|
|
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
|
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
|
}
|
|
|
|
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
|
|
{
|
|
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
|
var request = new CreateResponse { Model = "test-model" };
|
|
var stream = new ResponseEventStream(mockContext.Object, request);
|
|
return (stream, mockContext);
|
|
}
|
|
|
|
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
|
|
{
|
|
foreach (var item in source)
|
|
{
|
|
yield return item;
|
|
}
|
|
|
|
await Task.CompletedTask;
|
|
}
|
|
}
|