Files
Victor Dibia 9124d51e0e Python: .NET: Fix .NET conversation memory in DevUI (#3484) (#4294)
* 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
2026-03-02 10:34:25 +00:00

759 lines
27 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
internal static class TestHelpers
{
/// <summary>
/// Simple mock implementation of IChatClient for basic testing purposes.
/// </summary>
internal sealed class SimpleMockChatClient : IChatClient
{
private readonly string _responseText;
public ChatOptions? LastChatOptions { get; private set; }
public SimpleMockChatClient(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)
{
if (options is not null)
{
this.LastChatOptions = options;
}
// Count input messages to simulate context size
int messageCount = messages.Count();
ChatMessage message = new(ChatRole.Assistant, this._responseText);
ChatResponse response = new([message])
{
ModelId = "test-model",
FinishReason = ChatFinishReason.Stop,
Usage = new UsageDetails
{
InputTokenCount = 10 + (messageCount * 5), // More messages = more tokens
OutputTokenCount = 5,
TotalTokenCount = 15 + (messageCount * 5)
}
};
return Task.FromResult(response);
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (options is not null)
{
this.LastChatOptions = options;
}
await Task.Delay(1, cancellationToken);
// Count input messages to simulate context size
int messageCount = messages.Count();
// Split response into words to simulate streaming
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
};
// Add usage to the last update
if (i == words.Length - 1)
{
update.Contents.Add(new UsageContent(new UsageDetails
{
InputTokenCount = 10 + (messageCount * 5),
OutputTokenCount = 5,
TotalTokenCount = 15 + (messageCount * 5)
}));
}
yield return update;
}
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose()
{
}
}
/// <summary>
/// Stateful mock implementation of IChatClient that returns different responses for each call.
/// </summary>
internal sealed class StatefulMockChatClient : IChatClient
{
private readonly string[] _responseTexts;
private int _callIndex;
public StatefulMockChatClient(string[] responseTexts)
{
this._responseTexts = responseTexts;
this._callIndex = 0;
}
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)
{
// Get the response text for this call
string responseText = this._callIndex < this._responseTexts.Length
? this._responseTexts[this._callIndex]
: this._responseTexts[this._responseTexts.Length - 1];
this._callIndex++;
// Count input messages to simulate context size
int messageCount = messages.Count();
ChatMessage message = new(ChatRole.Assistant, responseText);
ChatResponse response = new([message])
{
ModelId = "test-model",
FinishReason = ChatFinishReason.Stop,
Usage = new UsageDetails
{
InputTokenCount = 10 + (messageCount * 5), // More messages = more tokens
OutputTokenCount = 5,
TotalTokenCount = 15 + (messageCount * 5)
}
};
return Task.FromResult(response);
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken);
// Get the response text for this call
string responseText = this._callIndex < this._responseTexts.Length
? this._responseTexts[this._callIndex]
: this._responseTexts[this._responseTexts.Length - 1];
this._callIndex++;
// Count input messages to simulate context size
int messageCount = messages.Count();
// Split response into words to simulate streaming
string[] words = 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
};
// Add usage to the last update
if (i == words.Length - 1)
{
update.Contents.Add(new UsageContent(new UsageDetails
{
InputTokenCount = 10 + (messageCount * 5),
OutputTokenCount = 5,
TotalTokenCount = 15 + (messageCount * 5)
}));
}
yield return update;
}
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose()
{
}
}
/// <summary>
/// Mock implementation of IChatClient that returns responses with image content.
/// </summary>
internal sealed class ImageContentMockChatClient : IChatClient
{
private readonly string _imageUrl;
public ImageContentMockChatClient(string imageUrl = "https://example.com/image.png")
{
this._imageUrl = imageUrl;
}
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)
{
ChatMessage message = new(ChatRole.Assistant, [
new TextContent("Here is an image:"),
new UriContent(this._imageUrl, "image/png")
]);
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)
{
await Task.Delay(1, cancellationToken);
yield return new ChatResponseUpdate
{
Contents = [new TextContent("Here is an image:")],
Role = ChatRole.Assistant
};
yield return new ChatResponseUpdate
{
Contents = [
new UriContent(this._imageUrl, "image/png"),
new UsageContent(new UsageDetails
{
InputTokenCount = 10,
OutputTokenCount = 5,
TotalTokenCount = 15
})
],
Role = ChatRole.Assistant
};
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose()
{
}
}
/// <summary>
/// Mock implementation of IChatClient that returns responses with audio content.
/// </summary>
internal sealed class AudioContentMockChatClient : IChatClient
{
private readonly byte[] _audioData;
private readonly string _transcript;
public AudioContentMockChatClient(string audioData = "base64audiodata", string transcript = "This is a transcript")
{
this._audioData = System.Text.Encoding.UTF8.GetBytes(audioData);
this._transcript = transcript;
}
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)
{
ChatMessage message = new(ChatRole.Assistant, [
new DataContent(this._audioData, "audio/wav")
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
["transcript"] = this._transcript
}
}
]);
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)
{
await Task.Delay(1, cancellationToken);
yield return new ChatResponseUpdate
{
Contents = [
new DataContent(this._audioData, "audio/wav")
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
["transcript"] = this._transcript
}
},
new UsageContent(new UsageDetails
{
InputTokenCount = 10,
OutputTokenCount = 5,
TotalTokenCount = 15
})
],
Role = ChatRole.Assistant
};
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose()
{
}
}
/// <summary>
/// Mock implementation of IChatClient that returns responses with function calls.
/// </summary>
internal sealed class FunctionCallMockChatClient : IChatClient
{
private readonly string _functionName;
private readonly Dictionary<string, object?> _arguments;
public FunctionCallMockChatClient(string functionName = "test_function", string arguments = "{\"param\":\"value\"}")
{
this._functionName = functionName;
this._arguments = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(arguments) ?? [];
}
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)
{
ChatMessage message = new(ChatRole.Assistant, [
new FunctionCallContent("call_123", this._functionName)
{
Arguments = this._arguments
}
]);
ChatResponse response = new([message])
{
ModelId = "test-model",
FinishReason = ChatFinishReason.ToolCalls,
Usage = new UsageDetails
{
InputTokenCount = 80,
OutputTokenCount = 25,
TotalTokenCount = 105
}
};
return Task.FromResult(response);
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken);
yield return new ChatResponseUpdate
{
Contents = [
new FunctionCallContent("call_123", this._functionName)
{
Arguments = this._arguments
},
new UsageContent(new UsageDetails
{
InputTokenCount = 80,
OutputTokenCount = 25,
TotalTokenCount = 105
})
],
Role = ChatRole.Assistant
};
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose()
{
}
}
/// <summary>
/// Mock implementation of IChatClient that returns mixed content types.
/// </summary>
internal sealed class MixedContentMockChatClient : IChatClient
{
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)
{
ChatMessage message = new(ChatRole.Assistant, [
new TextContent("Here are multiple content types:"),
new UriContent("https://example.com/image.png", "image/png"),
new TextContent("And some more text after the image.")
]);
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)
{
await Task.Delay(1, cancellationToken);
yield return new ChatResponseUpdate
{
Contents = [new TextContent("Here"), new TextContent(" are"), new TextContent(" multiple")],
Role = ChatRole.Assistant
};
yield return new ChatResponseUpdate
{
Contents = [new TextContent(" content"), new TextContent(" types:")],
Role = ChatRole.Assistant
};
yield return new ChatResponseUpdate
{
Contents = [new UriContent("https://example.com/image.png", "image/png")],
Role = ChatRole.Assistant
};
yield return new ChatResponseUpdate
{
Contents = [new TextContent("And"), new TextContent(" some"), new TextContent(" more")],
Role = ChatRole.Assistant
};
yield return new ChatResponseUpdate
{
Contents = [
new TextContent(" text"),
new TextContent(" after"),
new TextContent(" the"),
new TextContent(" image."),
new UsageContent(new UsageDetails
{
InputTokenCount = 10,
OutputTokenCount = 5,
TotalTokenCount = 15
})
],
Role = ChatRole.Assistant
};
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose()
{
}
}
/// <summary>
/// Mock implementation of IChatClient that returns function call content for tool testing.
/// </summary>
internal sealed class ToolCallMockChatClient : IChatClient
{
private readonly string _functionName;
private readonly Dictionary<string, object?> _arguments;
public ToolCallMockChatClient(string functionName, string argumentsJson)
{
this._functionName = functionName;
// Parse JSON arguments into dictionary
using var doc = System.Text.Json.JsonDocument.Parse(argumentsJson);
this._arguments = [];
foreach (var prop in doc.RootElement.EnumerateObject())
{
this._arguments[prop.Name] = prop.Value.ValueKind switch
{
System.Text.Json.JsonValueKind.String => prop.Value.GetString(),
System.Text.Json.JsonValueKind.Number => prop.Value.GetDouble(),
System.Text.Json.JsonValueKind.True => true,
System.Text.Json.JsonValueKind.False => false,
System.Text.Json.JsonValueKind.Null => null,
_ => prop.Value.ToString()
};
}
}
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)
{
int messageCount = messages.Count();
FunctionCallContent functionCall = new("call_test123", this._functionName, this._arguments);
ChatMessage message = new(ChatRole.Assistant, [functionCall]);
ChatResponse response = new([message])
{
ModelId = "test-model",
FinishReason = ChatFinishReason.ToolCalls,
Usage = new UsageDetails
{
InputTokenCount = 10 + (messageCount * 5),
OutputTokenCount = 5,
TotalTokenCount = 15 + (messageCount * 5)
}
};
return Task.FromResult(response);
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken);
int messageCount = messages.Count();
FunctionCallContent functionCall = new("call_test123", this._functionName, this._arguments);
yield return new ChatResponseUpdate
{
Contents = [functionCall, new UsageContent(new UsageDetails
{
InputTokenCount = 10 + (messageCount * 5),
OutputTokenCount = 5,
TotalTokenCount = 15 + (messageCount * 5)
})],
Role = ChatRole.Assistant
};
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose()
{
}
}
/// <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>
internal sealed class CustomContentMockChatClient : IChatClient
{
private readonly Func<ChatMessage, IEnumerable<AIContent>> _contentProvider;
public CustomContentMockChatClient(Func<ChatMessage, IEnumerable<AIContent>> contentProvider)
{
this._contentProvider = contentProvider;
}
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)
{
ChatMessage lastMessage = messages.Last();
IEnumerable<AIContent> contents = this._contentProvider(lastMessage);
ChatMessage message = new(ChatRole.Assistant, contents.ToList());
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)
{
await Task.Delay(1, cancellationToken);
ChatMessage lastMessage = messages.Last();
IEnumerable<AIContent> contents = this._contentProvider(lastMessage);
List<AIContent> contentList = contents.ToList();
// Stream each content item separately
for (int i = 0; i < contentList.Count; i++)
{
List<AIContent> updateContents = [contentList[i]];
// Add usage to the last update
if (i == contentList.Count - 1)
{
updateContents.Add(new UsageContent(new UsageDetails
{
InputTokenCount = 10,
OutputTokenCount = 5,
TotalTokenCount = 15
}));
}
yield return new ChatResponseUpdate
{
Contents = updateContents,
Role = ChatRole.Assistant
};
}
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose()
{
}
}
}