mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* Fix .NET conversation memory in DevUI (#3484) * formatting fixes * fix memory regression in python devui , fix for #4123 * Fix for #3983: Added _get_event_type() helper that safely accesses event type on both objects (.type) and dicts (.get("type")). Replaced all 4 bare event.type accesses in _executor.py (lines 267, 477, 499, 523). Root cause: PR #3690 changed event.__class__.__name__ == "RequestInfoEvent" (safe) to event.type == "request_info" (crashes on dicts), but _execute_workflow still yields raw dicts on error paths. Test: test_workflow_error_yields_dict_event_without_crash — mocks a workflow that raises, verifies execute_entity consumes the dict error events without crashing. * format fixes * lint fixes
This commit is contained in:
committed by
GitHub
Unverified
parent
0d6b9d61a5
commit
9124d51e0e
@@ -31,6 +31,7 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
|
||||
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
|
||||
AgentInvocationContext context,
|
||||
CreateResponse request,
|
||||
IReadOnlyList<ChatMessage>? conversationHistory = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Create options with properties from the request
|
||||
@@ -51,9 +52,14 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
|
||||
};
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
|
||||
// Convert input to chat messages
|
||||
// Convert input to chat messages, prepending conversation history if available
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
if (conversationHistory is not null)
|
||||
{
|
||||
messages.AddRange(conversationHistory);
|
||||
}
|
||||
|
||||
foreach (var inputMessage in request.Input.GetInputMessages())
|
||||
{
|
||||
messages.Add(inputMessage.ToChatMessage());
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Converts stored <see cref="ItemResource"/> objects back to <see cref="ChatMessage"/> objects
|
||||
/// for injecting conversation history into agent execution.
|
||||
/// </summary>
|
||||
internal static class ItemResourceConversions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a sequence of <see cref="ItemResource"/> items to a list of <see cref="ChatMessage"/> objects.
|
||||
/// Only converts message, function call, and function result items. Other item types are skipped.
|
||||
/// </summary>
|
||||
public static List<ChatMessage> ToChatMessages(IEnumerable<ItemResource> items)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case ResponsesUserMessageItemResource userMsg:
|
||||
messages.Add(new ChatMessage(ChatRole.User, ConvertContents(userMsg.Content)));
|
||||
break;
|
||||
|
||||
case ResponsesAssistantMessageItemResource assistantMsg:
|
||||
messages.Add(new ChatMessage(ChatRole.Assistant, ConvertContents(assistantMsg.Content)));
|
||||
break;
|
||||
|
||||
case ResponsesSystemMessageItemResource systemMsg:
|
||||
messages.Add(new ChatMessage(ChatRole.System, ConvertContents(systemMsg.Content)));
|
||||
break;
|
||||
|
||||
case ResponsesDeveloperMessageItemResource developerMsg:
|
||||
messages.Add(new ChatMessage(new ChatRole("developer"), ConvertContents(developerMsg.Content)));
|
||||
break;
|
||||
|
||||
case FunctionToolCallItemResource funcCall:
|
||||
var arguments = ParseArguments(funcCall.Arguments);
|
||||
messages.Add(new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)
|
||||
]));
|
||||
break;
|
||||
|
||||
case FunctionToolCallOutputItemResource funcOutput:
|
||||
messages.Add(new ChatMessage(ChatRole.Tool,
|
||||
[
|
||||
new FunctionResultContent(funcOutput.CallId, funcOutput.Output)
|
||||
]));
|
||||
break;
|
||||
|
||||
// Skip all other item types (reasoning, executor_action, web_search, etc.)
|
||||
// They are not relevant for conversation context.
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private static List<AIContent> ConvertContents(List<ItemContent> contents)
|
||||
{
|
||||
var result = new List<AIContent>();
|
||||
foreach (var content in contents)
|
||||
{
|
||||
var aiContent = ItemContentConverter.ToAIContent(content);
|
||||
if (aiContent is not null)
|
||||
{
|
||||
result.Add(aiContent);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?>? ParseArguments(string? argumentsJson)
|
||||
{
|
||||
if (string.IsNullOrEmpty(argumentsJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(argumentsJson);
|
||||
var result = new Dictionary<string, object?>();
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
result[property.Name] = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString(),
|
||||
JsonValueKind.Number => property.Value.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => property.Value.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
|
||||
AgentInvocationContext context,
|
||||
CreateResponse request,
|
||||
IReadOnlyList<ChatMessage>? conversationHistory = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string agentName = GetAgentName(request)!;
|
||||
@@ -105,6 +106,11 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
if (conversationHistory is not null)
|
||||
{
|
||||
messages.AddRange(conversationHistory);
|
||||
}
|
||||
|
||||
foreach (var inputMessage in request.Input.GetInputMessages())
|
||||
{
|
||||
messages.Add(inputMessage.ToChatMessage());
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
|
||||
|
||||
@@ -28,10 +29,12 @@ internal interface IResponseExecutor
|
||||
/// </summary>
|
||||
/// <param name="context">The agent invocation context containing the ID generator and other context information.</param>
|
||||
/// <param name="request">The create response request.</param>
|
||||
/// <param name="conversationHistory">Optional prior conversation messages to prepend to the agent's input.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of streaming response events.</returns>
|
||||
IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
|
||||
AgentInvocationContext context,
|
||||
CreateResponse request,
|
||||
IReadOnlyList<ChatMessage>? conversationHistory = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
+18
-1
@@ -425,11 +425,28 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
|
||||
// Create agent invocation context
|
||||
var context = new AgentInvocationContext(new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id));
|
||||
|
||||
// Load conversation history if a conversation ID is provided
|
||||
IReadOnlyList<Extensions.AI.ChatMessage>? conversationHistory = null;
|
||||
if (this._conversationStorage is not null && request.Conversation?.Id is not null)
|
||||
{
|
||||
var itemsResult = await this._conversationStorage.ListItemsAsync(
|
||||
request.Conversation.Id,
|
||||
limit: 100,
|
||||
order: SortOrder.Ascending,
|
||||
cancellationToken: linkedCts.Token).ConfigureAwait(false);
|
||||
|
||||
var history = ItemResourceConversions.ToChatMessages(itemsResult.Data);
|
||||
if (history.Count > 0)
|
||||
{
|
||||
conversationHistory = history;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect output items for conversation storage
|
||||
List<ItemResource> outputItems = [];
|
||||
|
||||
// Execute using the injected executor
|
||||
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, linkedCts.Token).ConfigureAwait(false))
|
||||
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, conversationHistory, linkedCts.Token).ConfigureAwait(false))
|
||||
{
|
||||
state.AddStreamingEvent(streamingEvent);
|
||||
|
||||
|
||||
+92
@@ -1201,6 +1201,75 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
|
||||
Assert.Null(mockChatClient.LastChatOptions.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that conversation history is passed to the agent on subsequent requests.
|
||||
/// This test reproduces the bug described in GitHub issue #3484.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateResponse_WithConversation_SecondRequestIncludesPriorMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "memory-agent";
|
||||
const string Instructions = "You are a helpful assistant.";
|
||||
const string AgentResponse = "Nice to meet you Alice";
|
||||
|
||||
var mockChatClient = new TestHelpers.ConversationMemoryMockChatClient(AgentResponse);
|
||||
this._httpClient = await this.CreateTestServerWithCustomClientAndConversationsAsync(
|
||||
AgentName, Instructions, mockChatClient);
|
||||
|
||||
// Create a conversation
|
||||
string createConvJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
new { metadata = new { agent_id = AgentName } });
|
||||
using StringContent createConvContent = new(createConvJson, Encoding.UTF8, "application/json");
|
||||
HttpResponseMessage createConvResponse = await this._httpClient.PostAsync(
|
||||
new Uri("/v1/conversations", UriKind.Relative), createConvContent);
|
||||
Assert.True(createConvResponse.IsSuccessStatusCode);
|
||||
|
||||
string convJson = await createConvResponse.Content.ReadAsStringAsync();
|
||||
using var convDoc = System.Text.Json.JsonDocument.Parse(convJson);
|
||||
string conversationId = convDoc.RootElement.GetProperty("id").GetString()!;
|
||||
|
||||
// Act - First message
|
||||
await this.SendRawResponseAsync(AgentName, "My name is Alice", conversationId, stream: false);
|
||||
|
||||
// Act - Second message in same conversation
|
||||
await this.SendRawResponseAsync(AgentName, "What is my name?", conversationId, stream: false);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, mockChatClient.CallHistory.Count);
|
||||
|
||||
// First call: should have 1 message (just the user input)
|
||||
Assert.Single(mockChatClient.CallHistory[0]);
|
||||
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[0][0].Role);
|
||||
|
||||
// Second call: should have 3 messages (prior user + prior assistant + new user)
|
||||
Assert.Equal(3, mockChatClient.CallHistory[1].Count);
|
||||
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[1][0].Role);
|
||||
Assert.Equal(ChatRole.Assistant, mockChatClient.CallHistory[1][1].Role);
|
||||
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[1][2].Role);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendRawResponseAsync(
|
||||
string agentName, string input, string conversationId, bool stream)
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
input,
|
||||
agent = new { name = agentName },
|
||||
conversation = conversationId,
|
||||
stream
|
||||
};
|
||||
string json = System.Text.Json.JsonSerializer.Serialize(requestBody);
|
||||
using StringContent content = new(json, Encoding.UTF8, "application/json");
|
||||
HttpResponseMessage response = await this._httpClient!.PostAsync(
|
||||
new Uri($"/{agentName}/v1/responses", UriKind.Relative), content);
|
||||
Assert.True(response.IsSuccessStatusCode, $"Response failed: {response.StatusCode}");
|
||||
|
||||
// Consume the full response body to ensure execution completes
|
||||
await response.Content.ReadAsStringAsync();
|
||||
return response;
|
||||
}
|
||||
|
||||
private ResponsesClient CreateResponseClient(string agentName)
|
||||
{
|
||||
return new ResponsesClient(
|
||||
@@ -1272,6 +1341,29 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
|
||||
return testServer.CreateClient();
|
||||
}
|
||||
|
||||
private async Task<HttpClient> CreateTestServerWithCustomClientAndConversationsAsync(string agentName, string instructions, IChatClient chatClient)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
builder.Services.AddKeyedSingleton($"chat-client-{agentName}", chatClient);
|
||||
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: $"chat-client-{agentName}");
|
||||
builder.AddOpenAIResponses();
|
||||
builder.AddOpenAIConversations();
|
||||
|
||||
this._app = builder.Build();
|
||||
AIAgent agent = this._app.Services.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
this._app.MapOpenAIResponses(agent);
|
||||
this._app.MapOpenAIConversations();
|
||||
|
||||
await this._app.StartAsync();
|
||||
|
||||
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
|
||||
return testServer.CreateClient();
|
||||
}
|
||||
|
||||
private async Task<HttpClient> CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
|
||||
@@ -597,6 +597,86 @@ internal static class TestHelpers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock IChatClient that captures the full message list on each call.
|
||||
/// Used to verify conversation history is passed correctly.
|
||||
/// </summary>
|
||||
internal sealed class ConversationMemoryMockChatClient : IChatClient
|
||||
{
|
||||
private readonly string _responseText;
|
||||
|
||||
/// <summary>Each entry is the messages list received for that call.</summary>
|
||||
public List<List<ChatMessage>> CallHistory { get; } = [];
|
||||
|
||||
public ConversationMemoryMockChatClient(string responseText = "Test response")
|
||||
{
|
||||
this._responseText = responseText;
|
||||
}
|
||||
|
||||
public ChatClientMetadata Metadata { get; } = new("Test", new Uri("https://test.example.com"), "test-model");
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.CallHistory.Add(messages.ToList());
|
||||
|
||||
ChatMessage message = new(ChatRole.Assistant, this._responseText);
|
||||
ChatResponse response = new([message])
|
||||
{
|
||||
ModelId = "test-model",
|
||||
FinishReason = ChatFinishReason.Stop,
|
||||
Usage = new UsageDetails
|
||||
{
|
||||
InputTokenCount = 10,
|
||||
OutputTokenCount = 5,
|
||||
TotalTokenCount = 15
|
||||
}
|
||||
};
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.CallHistory.Add(messages.ToList());
|
||||
await Task.Delay(1, cancellationToken);
|
||||
|
||||
string[] words = this._responseText.Split(' ');
|
||||
for (int i = 0; i < words.Length; i++)
|
||||
{
|
||||
string content = i < words.Length - 1 ? words[i] + " " : words[i];
|
||||
ChatResponseUpdate update = new()
|
||||
{
|
||||
Contents = [new TextContent(content)],
|
||||
Role = ChatRole.Assistant
|
||||
};
|
||||
|
||||
if (i == words.Length - 1)
|
||||
{
|
||||
update.Contents.Add(new UsageContent(new UsageDetails
|
||||
{
|
||||
InputTokenCount = 10,
|
||||
OutputTokenCount = 5,
|
||||
TotalTokenCount = 15
|
||||
}));
|
||||
}
|
||||
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) =>
|
||||
serviceType.IsInstanceOfType(this) ? this : null;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom content mock implementation of IChatClient that returns custom content based on a provider function.
|
||||
/// </summary>
|
||||
|
||||
@@ -21,6 +21,13 @@ from .models._discovery_models import EntityInfo
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_event_type(event: Any) -> str | None:
|
||||
"""Safely get the type of an event, handling both objects and dicts."""
|
||||
if isinstance(event, dict):
|
||||
return event.get("type")
|
||||
return getattr(event, "type", None)
|
||||
|
||||
|
||||
class EntityNotFoundError(Exception):
|
||||
"""Raised when an entity is not found."""
|
||||
|
||||
@@ -264,7 +271,7 @@ class AgentFrameworkExecutor:
|
||||
elif entity_info.type == "workflow":
|
||||
async for event in self._execute_workflow(entity_obj, request, trace_collector):
|
||||
# Log request_info event (type='request_info') for debugging HIL flow
|
||||
if event.type == "request_info":
|
||||
if _get_event_type(event) == "request_info":
|
||||
logger.info(
|
||||
"🔔 [EXECUTOR] request_info event (type='request_info') detected from workflow!"
|
||||
)
|
||||
@@ -330,19 +337,22 @@ class AgentFrameworkExecutor:
|
||||
|
||||
# Agent must have run() method - use stream=True for streaming
|
||||
if hasattr(agent, "run") and callable(agent.run):
|
||||
# Use Agent Framework's run() with stream=True for streaming
|
||||
# Capture the stream reference so we can call get_final_response()
|
||||
# after iteration. This triggers result hooks (after_run providers
|
||||
# like InMemoryHistoryProvider) that persist conversation history.
|
||||
run_kwargs: dict[str, Any] = {"stream": True}
|
||||
if session:
|
||||
async for update in agent.run(user_message, stream=True, session=session):
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
yield trace_event
|
||||
run_kwargs["session"] = session
|
||||
|
||||
yield update
|
||||
else:
|
||||
async for update in agent.run(user_message, stream=True):
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
yield trace_event
|
||||
stream = agent.run(user_message, **run_kwargs)
|
||||
async for update in stream:
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
yield trace_event
|
||||
|
||||
yield update
|
||||
yield update
|
||||
|
||||
# Finalize stream to trigger result hooks (saves conversation history)
|
||||
await stream.get_final_response()
|
||||
else:
|
||||
raise ValueError("Agent must implement run() method")
|
||||
|
||||
@@ -471,7 +481,7 @@ class AgentFrameworkExecutor:
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
):
|
||||
# Enrich new request_info events that may come from subsequent HIL requests
|
||||
if event.type == "request_info":
|
||||
if _get_event_type(event) == "request_info":
|
||||
self._enrich_request_info_event_with_response_schema(event, workflow)
|
||||
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
@@ -493,7 +503,7 @@ class AgentFrameworkExecutor:
|
||||
checkpoint_id=checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
):
|
||||
if event.type == "request_info":
|
||||
if _get_event_type(event) == "request_info":
|
||||
self._enrich_request_info_event_with_response_schema(event, workflow)
|
||||
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
@@ -517,7 +527,7 @@ class AgentFrameworkExecutor:
|
||||
parsed_input = await self._parse_workflow_input(workflow, request.input)
|
||||
|
||||
async for event in workflow.run(parsed_input, stream=True, checkpoint_storage=checkpoint_storage):
|
||||
if event.type == "request_info":
|
||||
if _get_event_type(event) == "request_info":
|
||||
self._enrich_request_info_event_with_response_schema(event, workflow)
|
||||
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
|
||||
@@ -741,6 +741,51 @@ async def test_full_pipeline_workflow_output_event_serialization():
|
||||
assert len(output_events) >= 3, f"Expected 3+ output events for yield_output calls, got {len(output_events)}"
|
||||
|
||||
|
||||
async def test_workflow_error_yields_dict_event_without_crash():
|
||||
"""Test that workflow errors don't crash execute_entity (#3983).
|
||||
|
||||
When a workflow raises an exception, _execute_workflow yields a raw dict
|
||||
{"type": "error", ...}. The execute_entity caller must handle both dict
|
||||
events and object events without crashing on attribute access.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from agent_framework_devui.models._discovery_models import EntityInfo
|
||||
|
||||
discovery = MagicMock(spec=EntityDiscovery)
|
||||
mapper = MessageMapper()
|
||||
executor = AgentFrameworkExecutor(discovery, mapper)
|
||||
|
||||
entity_info = EntityInfo(id="bad_wf", name="bad_wf", type="workflow", framework="agent_framework")
|
||||
discovery.get_entity_info.return_value = entity_info
|
||||
|
||||
# Mock workflow whose run() raises
|
||||
mock_workflow = MagicMock()
|
||||
mock_workflow.name = "bad_wf"
|
||||
|
||||
def failing_run(*args, **kwargs):
|
||||
raise RuntimeError("Sorry, something went wrong.")
|
||||
|
||||
mock_workflow.run = failing_run
|
||||
discovery.load_entity = AsyncMock(return_value=mock_workflow)
|
||||
|
||||
request = AgentFrameworkRequest(
|
||||
model="test",
|
||||
input="hello",
|
||||
metadata={"entity_id": "bad_wf"},
|
||||
)
|
||||
|
||||
events = []
|
||||
# This should NOT raise AttributeError: 'dict' object has no attribute 'type'
|
||||
async for event in executor.execute_entity("bad_wf", request):
|
||||
events.append(event)
|
||||
|
||||
# Should get at least one error event
|
||||
assert len(events) > 0
|
||||
error_events = [e for e in events if isinstance(e, dict) and e.get("type") == "error"]
|
||||
assert len(error_events) > 0, f"Expected error dict events, got: {events}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Simple test runner
|
||||
async def run_tests():
|
||||
|
||||
Reference in New Issue
Block a user