diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs index c1bc7b5c0..2f513248c 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs @@ -19,6 +19,7 @@ using Netclaw.Actors.Protocol; using Netclaw.Actors.Sessions; using Netclaw.Actors.Tests.Sessions; +using FakeChatClient = Netclaw.Tests.Utilities.FakeChatClient; using Netclaw.Channels.Discord; using Netclaw.Configuration; using Netclaw.Security; @@ -36,7 +37,7 @@ public sealed class DiscordFileFlowIntegrationTests : TestKit private static readonly byte[] FakePngBytes = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="); - private readonly ImageCapturingChatClient _chatClient = new(); + private readonly FakeChatClient _chatClient = new(); private readonly RecordingDiscordReplyClient _replyClient = new(); private readonly FakeDiscordFileHandler _httpHandler = new(); private readonly NetclawPaths _paths = new(Path.Combine( @@ -136,7 +137,9 @@ await AwaitAssertAsync(() => Assert.True(_httpHandler.RequestCount > 0, "Expected file download request"); Assert.Contains("cdn.discordapp.com", _httpHandler.LastRequestUri?.Host ?? ""); - Assert.True(_chatClient.ReceivedImageContent, + Assert.True( + _chatClient.LastReceivedMessages is not null + && _chatClient.LastReceivedMessages.SelectMany(m => m.Contents).OfType().Any(), "Expected LLM to receive DataContent (image) in chat messages"); var sessionId = new SessionId("ch-1/msg-1000"); @@ -191,7 +194,9 @@ await AwaitAssertAsync(() => }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); Assert.True(_httpHandler.RequestCount > 0, "Expected file download request"); - Assert.True(_chatClient.ReceivedImageContent, + Assert.True( + _chatClient.LastReceivedMessages is not null + && _chatClient.LastReceivedMessages.SelectMany(m => m.Contents).OfType().Any(), "Expected LLM to receive image content from attachment-only message"); } @@ -233,7 +238,9 @@ await AwaitAssertAsync(() => "Expected at least one Discord reply to be posted"); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); - Assert.False(_chatClient.ReceivedImageContent, + Assert.False( + _chatClient.LastReceivedMessages is not null + && _chatClient.LastReceivedMessages.SelectMany(m => m.Contents).OfType().Any(), "Expected LLM not to receive image when scanner fails"); Assert.Contains(_replyClient.Posts, @@ -284,7 +291,9 @@ await AwaitAssertAsync(() => "Expected at least one Discord reply to be posted"); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); - Assert.True(_chatClient.ReceivedImageContent, + Assert.True( + _chatClient.LastReceivedMessages is not null + && _chatClient.LastReceivedMessages.SelectMany(m => m.Contents).OfType().Any(), "Expected LLM to receive DataContent (image) via real MagicByteContentScanner"); } @@ -349,58 +358,6 @@ protected override Task SendAsync( } } - private sealed class ImageCapturingChatClient : IChatClient - { - private int _callCount; - public int CallCount => _callCount; - public volatile bool ReceivedImageContent; - - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref _callCount); - - foreach (var msg in messages) - { - if (msg.Contents.OfType().Any()) - ReceivedImageContent = true; - } - - var contents = new List - { - new TextContent($"[fake] I see your image (call #{_callCount})") - }; - var response = new ChatResponse(new ChatMessage( - Microsoft.Extensions.AI.ChatRole.Assistant, - contents)); - return Task.FromResult(response); - } - - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - => CreateStreamingAsync(messages, options, cancellationToken); - - private async IAsyncEnumerable CreateStreamingAsync( - IEnumerable messages, - ChatOptions? options, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) - { - var response = await GetResponseAsync(messages, options, cancellationToken); - foreach (var update in response.ToChatResponseUpdates()) - { - cancellationToken.ThrowIfCancellationRequested(); - yield return update; - } - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - public void Dispose() { } - } - private sealed class ImageCapabilityResolver : IModelCapabilityResolver { public Task ResolveAsync( diff --git a/src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs index 4ea0eb6c0..819f5d8a9 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs @@ -19,6 +19,7 @@ using Netclaw.Actors.Protocol; using Netclaw.Actors.Sessions; using Netclaw.Actors.Tests.Sessions; +using FakeChatClient = Netclaw.Tests.Utilities.FakeChatClient; using Netclaw.Channels.Slack; using Netclaw.Configuration; using Netclaw.Security; @@ -49,7 +50,7 @@ public sealed class SlackAttachmentIngressVisionTests : TestKit private static readonly byte[] FakePlainTextBytes = "meeting notes\n- discuss Q2 roadmap\n- assign OKRs\n"u8.ToArray(); - private readonly RecordingChatClient _chatClient = new(); + private readonly FakeChatClient _chatClient = new() { ResponseText = "ok" }; private readonly RecordingReplyClient _replyClient = new(); private readonly ConfigurableFakeSlackFileHandler _httpHandler = new(); private readonly NetclawPaths _paths = new(Path.Combine( @@ -61,6 +62,17 @@ public SlackAttachmentIngressVisionTests(ITestOutputHelper output) : base(output _paths.EnsureDirectoriesExist(); } + /// + /// Contents of every USER-role message across all LLM calls so far, in + /// call order — mirrors the old RecordingChatClient.ReceivedMessages + /// accumulator that this test suite asserted against. + /// + private IReadOnlyList> ReceivedUserMessageContents => + _chatClient.ReceivedMessagesByCall + .SelectMany(call => call.Where(m => m.Role == Microsoft.Extensions.AI.ChatRole.User)) + .Select(m => m.Contents) + .ToList(); + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) { services.AddSingleton(new SingleClientProvider(_chatClient)); @@ -191,13 +203,13 @@ public async Task Pdf_in_dm_is_saved_to_inbox_path_only_and_never_inlined() await AwaitAssertAsync(() => { - Assert.Contains(_chatClient.ReceivedMessages, + Assert.Contains(ReceivedUserMessageContents, contents => contents.Any(c => c is TextContent t && t.Text.Contains("[attachment]", StringComparison.Ordinal) && t.Text.Contains("report.pdf", StringComparison.Ordinal))); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); - var announcement = _chatClient.ReceivedMessages + var announcement = ReceivedUserMessageContents .SelectMany(m => m) .OfType() .First(t => t.Text.Contains("[attachment]", StringComparison.Ordinal) @@ -209,7 +221,7 @@ await AwaitAssertAsync(() => // PDFs are never handed to the LLM as DataContent — otherwise they'd // reach OpenAiCompatibleChatClient and be wrapped as a broken image_url. - var pdfDataContents = _chatClient.ReceivedMessages + var pdfDataContents = ReceivedUserMessageContents .SelectMany(m => m) .OfType() .Where(d => d.MediaType == "application/pdf"); @@ -254,13 +266,13 @@ public async Task Docx_in_dm_is_path_only_with_format_not_inlineable_note() await AwaitAssertAsync(() => { - Assert.Contains(_chatClient.ReceivedMessages, + Assert.Contains(ReceivedUserMessageContents, contents => contents.Any(c => c is TextContent t && t.Text.Contains("[attachment]", StringComparison.Ordinal) && t.Text.Contains("notes.docx", StringComparison.Ordinal))); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); - var announcement = _chatClient.ReceivedMessages + var announcement = ReceivedUserMessageContents .SelectMany(m => m) .OfType() .First(t => t.Text.Contains("[attachment]", StringComparison.Ordinal) @@ -270,7 +282,7 @@ await AwaitAssertAsync(() => // Docx is not inlineable — no DataContent for it should have been // forwarded to the LLM. - var docxDataContents = _chatClient.ReceivedMessages + var docxDataContents = ReceivedUserMessageContents .SelectMany(m => m) .OfType() .Where(d => d.MediaType?.Contains("wordprocessingml", StringComparison.Ordinal) == true); @@ -402,7 +414,7 @@ await AwaitAssertAsync(() => // Text content "batch upload" should still have reached the LLM. await AwaitAssertAsync(() => { - Assert.Contains(_chatClient.ReceivedMessages, + Assert.Contains(ReceivedUserMessageContents, contents => contents.Any(c => c is TextContent t && t.Text.Contains("batch upload", StringComparison.Ordinal))); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); @@ -509,7 +521,7 @@ public async Task PlainText_in_dm_flows_through_real_magic_byte_scanner() await AwaitAssertAsync(() => { - Assert.Contains(_chatClient.ReceivedMessages, + Assert.Contains(ReceivedUserMessageContents, contents => contents.Any(c => c is TextContent t && t.Text.Contains("[attachment]", StringComparison.Ordinal) && t.Text.Contains("notes.txt", StringComparison.Ordinal) @@ -555,7 +567,7 @@ public async Task OctetStream_png_in_dm_uses_verified_png_mime_downstream() await AwaitAssertAsync(() => { - Assert.Contains(_chatClient.ReceivedMessages, + Assert.Contains(ReceivedUserMessageContents, contents => contents.Any(c => c is TextContent t && t.Text.Contains("mime=\"image/png\"", StringComparison.Ordinal)) && contents.Any(c => c is DataContent d && d.MediaType == "image/png")); @@ -763,53 +775,6 @@ public Task UploadFileToThreadAsync( CancellationToken cancellationToken = default) => Task.CompletedTask; } - private sealed class RecordingChatClient : IChatClient - { - private readonly object _gate = new(); - private readonly List> _messages = []; - - public IReadOnlyList> ReceivedMessages - { - get - { - lock (_gate) - return _messages.ToList(); - } - } - - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - lock (_gate) - { - foreach (var msg in messages) - { - if (msg.Role == Microsoft.Extensions.AI.ChatRole.User) - _messages.Add(msg.Contents); - } - } - - return Task.FromResult(new ChatResponse([ - new ChatMessage(Microsoft.Extensions.AI.ChatRole.Assistant, "ok") - ])); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await GetResponseAsync(messages, options, cancellationToken); - yield return new ChatResponseUpdate(Microsoft.Extensions.AI.ChatRole.Assistant, "ok"); - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } - private sealed class AlwaysBlockContentScanner(string message) : IContentScanner { public Task ScanAsync( diff --git a/src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs index 625814e30..6d65f8961 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs @@ -23,6 +23,7 @@ using Netclaw.Actors.Tests.Channels.TestHelpers; using Netclaw.Actors.Tests.Hosting; using Netclaw.Actors.Tests.Sessions; +using FakeChatClient = Netclaw.Tests.Utilities.FakeChatClient; using Netclaw.Channels.Slack; using Netclaw.Configuration; using Netclaw.Security; @@ -46,7 +47,7 @@ public sealed class SlackFileFlowIntegrationTests : TestKit private static readonly byte[] FakePngBytes = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="); - private readonly ImageCapturingChatClient _chatClient = new(); + private readonly FakeChatClient _chatClient = new(); private readonly RecordingReplyClient _replyClient = new(); private readonly FakeSlackFileHandler _httpHandler = new(); private readonly NetclawPaths _paths = new(Path.Combine( @@ -176,7 +177,9 @@ await AwaitAssertAsync(() => Assert.Contains("files.slack.com", _httpHandler.LastRequestUri?.Host ?? ""); // Verify: the chat client received image content - Assert.True(_chatClient.ReceivedImageContent, + Assert.True( + _chatClient.LastReceivedMessages is not null + && _chatClient.LastReceivedMessages.SelectMany(m => m.Contents).OfType().Any(), "Expected LLM to receive DataContent (image) in chat messages"); // Verify: file was persisted to session media directory @@ -256,7 +259,9 @@ await AwaitAssertAsync(() => }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); Assert.True(_httpHandler.RequestCount > 0, "Expected file download request"); - Assert.True(_chatClient.ReceivedImageContent, + Assert.True( + _chatClient.LastReceivedMessages is not null + && _chatClient.LastReceivedMessages.SelectMany(m => m.Contents).OfType().Any(), "Expected LLM to receive image content"); var sessionId = new SessionId("C_TEST/2000.1"); @@ -329,7 +334,9 @@ await AwaitAssertAsync(() => }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); Assert.True(_httpHandler.RequestCount > 0, "Expected file download request"); - Assert.True(_chatClient.ReceivedImageContent, + Assert.True( + _chatClient.LastReceivedMessages is not null + && _chatClient.LastReceivedMessages.SelectMany(m => m.Contents).OfType().Any(), "Expected LLM to receive image content from file_share message"); var sessionId = new SessionId("D2/3000.1"); @@ -519,7 +526,7 @@ await AwaitAssertAsync(() => await AwaitAssertAsync(() => { Assert.Contains(_replyClient.PostedMessages, - message => message.Text.Contains("call #2", StringComparison.OrdinalIgnoreCase)); + message => message.Text.Contains("Response #2", StringComparison.OrdinalIgnoreCase)); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); } @@ -574,11 +581,11 @@ public async Task Retryable_slack_content_rejection_is_fed_back_to_session_for_c await AwaitAssertAsync(() => { Assert.Contains(_replyClient.PostedMessages, - message => message.Text.Contains("call #2", StringComparison.OrdinalIgnoreCase)); + message => message.Text.Contains("Response #2", StringComparison.OrdinalIgnoreCase)); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); Assert.DoesNotContain(_replyClient.PostedMessages, - message => message.Text.Contains("call #1", StringComparison.OrdinalIgnoreCase)); + message => message.Text.Contains("Response #1", StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -1269,7 +1276,9 @@ await AwaitAssertAsync(() => "Expected at least one Slack reply to be posted"); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); - Assert.True(_chatClient.ReceivedImageContent, + Assert.True( + _chatClient.LastReceivedMessages is not null + && _chatClient.LastReceivedMessages.SelectMany(m => m.Contents).OfType().Any(), "Expected LLM to receive DataContent (image) via real MagicByteContentScanner"); } @@ -1332,7 +1341,9 @@ await AwaitAssertAsync(() => "Expected at least one Slack reply to be posted"); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); - Assert.False(_chatClient.ReceivedImageContent, + Assert.False( + _chatClient.LastReceivedMessages is not null + && _chatClient.LastReceivedMessages.SelectMany(m => m.Contents).OfType().Any(), "Expected LLM not to receive image when scanner fails"); Assert.Contains(_replyClient.PostedMessages, @@ -1529,65 +1540,6 @@ public Task DetectAsync( } } - /// - /// Chat client that tracks whether it received image content in messages. - /// - private sealed class ImageCapturingChatClient : IChatClient - { - private int _callCount; - public int CallCount => _callCount; - public volatile bool ReceivedImageContent; - public Exception? Failure { get; set; } - - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref _callCount); - - if (Failure is not null) - throw Failure; - - foreach (var msg in messages) - { - if (msg.Contents.OfType().Any()) - ReceivedImageContent = true; - } - - var contents = new List - { - new TextContent($"[fake] I see your image (call #{_callCount})") - }; - var response = new ChatResponse(new ChatMessage( - Microsoft.Extensions.AI.ChatRole.Assistant, - contents)); - return Task.FromResult(response); - } - - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - => CreateStreamingAsync(messages, options, cancellationToken); - - private async IAsyncEnumerable CreateStreamingAsync( - IEnumerable messages, - ChatOptions? options, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) - { - var response = await GetResponseAsync(messages, options, cancellationToken); - foreach (var update in response.ToChatResponseUpdates()) - { - cancellationToken.ThrowIfCancellationRequested(); - yield return update; - } - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - public void Dispose() { } - } - /// /// Capability resolver that reports image input support so the modality gate /// doesn't strip DataContent from inbound messages. diff --git a/src/Netclaw.Actors.Tests/Channels/SlackThreadBackfillIntegrationTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackThreadBackfillIntegrationTests.cs index 55f5fec02..dbf125bdd 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackThreadBackfillIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackThreadBackfillIntegrationTests.cs @@ -22,6 +22,7 @@ using Netclaw.Actors.Sessions; using Netclaw.Actors.Protocol; using Netclaw.Actors.Tests.Sessions; +using FakeChatClient = Netclaw.Tests.Utilities.FakeChatClient; using Netclaw.Channels.Slack; using Netclaw.Configuration; using Netclaw.Security; @@ -40,7 +41,7 @@ public sealed class SlackThreadBackfillIntegrationTests : TestKit private static readonly byte[] FakePngBytes = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="); - private readonly ContextCapturingChatClient _chatClient = new(); + private readonly FakeChatClient _chatClient = new(); private readonly RecordingReplyClient _replyClient = new(); private readonly FakeSlackFileHandler _httpHandler = new(); private readonly NetclawPaths _paths = new(Path.Combine( @@ -164,7 +165,7 @@ await AwaitAssertAsync(() => }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); // Verify: LLM received one user turn containing history prelude + live mention. - var messages = _chatClient.LastMessages!; + var messages = _chatClient.LastReceivedMessages!; var userMessages = messages.Where(m => m.Role == AiChatRole.User).ToList(); Assert.Single(userMessages); @@ -390,7 +391,7 @@ await AwaitAssertAsync(() => Assert.True(_chatClient.CallCount > 0, "Expected at least one LLM call"); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); - var messages = _chatClient.LastMessages!; + var messages = _chatClient.LastReceivedMessages!; var userMessages = messages.Where(m => m.Role == AiChatRole.User).ToList(); Assert.Single(userMessages); @@ -515,7 +516,7 @@ await AwaitAssertAsync(() => Assert.True(_chatClient.CallCount > 0, "Expected at least one LLM call"); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); - var messages = _chatClient.LastMessages!; + var messages = _chatClient.LastReceivedMessages!; var userMessages = messages.Where(m => m.Role == AiChatRole.User).ToList(); Assert.Single(userMessages); @@ -626,7 +627,7 @@ await AwaitAssertAsync(() => Assert.True(_chatClient.CallCount > 0, "Expected at least one LLM call"); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); - var messages = _chatClient.LastMessages!; + var messages = _chatClient.LastReceivedMessages!; var userMessages = messages.Where(m => m.Role == AiChatRole.User).ToList(); Assert.Single(userMessages); @@ -757,7 +758,7 @@ await AwaitAssertAsync(() => Assert.True(_chatClient.CallCount > 0, "Expected at least one LLM call"); }, duration: TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken); - var messages = _chatClient.LastMessages!; + var messages = _chatClient.LastReceivedMessages!; var userMessages = messages.Where(m => m.Role == AiChatRole.User).ToList(); Assert.Single(userMessages); @@ -970,7 +971,7 @@ await AwaitAssertAsync(() => // rejection note lives under the public-channel attachment policy). // Under the post-fix design the backfill is its own LLM call; the // subsequent live inbound is a plain message without adopted context. - var backfillCall = _chatClient.Calls + var backfillCall = _chatClient.ReceivedMessagesByCall .Select((messages, idx) => (messages, idx)) .FirstOrDefault(c => { @@ -1121,54 +1122,6 @@ public Task UploadFileToThreadAsync( => Task.CompletedTask; } - /// - /// Chat client that captures the full message list sent to the LLM. - /// - private sealed class ContextCapturingChatClient : IChatClient - { - private int _callCount; - public int CallCount => _callCount; - public List? LastMessages { get; private set; } - public List> Calls { get; } = []; - - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref _callCount); - LastMessages = [.. messages]; - Calls.Add(LastMessages); - - var response = new ChatResponse(new ChatMessage( - AiChatRole.Assistant, - (IList)[new TextContent($"[fake response #{_callCount}]")])); - return Task.FromResult(response); - } - - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - => StreamAsync(messages, options, cancellationToken); - - private async IAsyncEnumerable StreamAsync( - IEnumerable messages, - ChatOptions? options, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) - { - var response = await GetResponseAsync(messages, options, cancellationToken); - foreach (var update in response.ToChatResponseUpdates()) - { - cancellationToken.ThrowIfCancellationRequested(); - yield return update; - } - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - public void Dispose() { } - } - private sealed class ImageCapabilityResolver : IModelCapabilityResolver { public Task ResolveAsync( diff --git a/src/Netclaw.Actors.Tests/Memory/MemoryCurationEvaluatorParityTests.cs b/src/Netclaw.Actors.Tests/Memory/MemoryCurationEvaluatorParityTests.cs index c428f0241..cc7012240 100644 --- a/src/Netclaw.Actors.Tests/Memory/MemoryCurationEvaluatorParityTests.cs +++ b/src/Netclaw.Actors.Tests/Memory/MemoryCurationEvaluatorParityTests.cs @@ -3,7 +3,6 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- -using System.Runtime.CompilerServices; using Akka.Event; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -11,9 +10,8 @@ using Netclaw.Actors.Memory; using Netclaw.Actors.Protocol; using Netclaw.Configuration; +using Netclaw.Tests.Utilities; using Xunit; -using AiChatMessage = Microsoft.Extensions.AI.ChatMessage; -using AiChatRole = Microsoft.Extensions.AI.ChatRole; namespace Netclaw.Actors.Tests.Memory; @@ -222,7 +220,7 @@ await SeedDocumentAsync( freshnessAtMs: 2000); var evaluator = new MemoryCurationEvaluator( - _store, (ILoggingAdapter)NoLogger.Instance, new ScriptedCurationChatClient("SKIP")); + _store, (ILoggingAdapter)NoLogger.Instance, new FakeChatClient { ResponseText = "SKIP" }); var decision = await evaluator.EvaluateAsync(operation, TestSessionId, ct); @@ -249,11 +247,15 @@ await SeedDocumentAsync( "Netclaw GitHub repository at https://github.com/netclaw-dev/netclaw, private repo", freshnessAtMs: 2000); - // Empty stream (no yields) reproduces a provider that returns nothing parseable — - // TryLlmEvaluationAsync must surface curation_llm_no_decision and fall through to - // the same deterministic auto-resolve path the no-LLM matrix case exercises. + // ResponseText = null makes FakeChatClient emit its default marker text + // ("[fake] Response #1") rather than a truly empty response, but the marker + // still isn't a recognized SKIP/CREATE/UPDATE/CONSOLIDATE keyword, so + // CurationPromptBuilder.ParseResponse still returns no decision and + // TryLlmEvaluationAsync still surfaces curation_llm_no_decision and falls + // through to the same deterministic auto-resolve path the no-LLM matrix case + // exercises. var evaluator = new MemoryCurationEvaluator( - _store, (ILoggingAdapter)NoLogger.Instance, new ScriptedCurationChatClient(responseText: null)); + _store, (ILoggingAdapter)NoLogger.Instance, new FakeChatClient { ResponseText = null }); var decision = await evaluator.EvaluateAsync(operation, TestSessionId, ct); @@ -339,34 +341,4 @@ private static void AssertSameDecision(CurationDecision expected, CurationDecisi else Assert.Equal(expected.ConsolidationTargetIds, actual.ConsolidationTargetIds); } - - /// - /// Minimal scripted : streams - /// as a single update, or nothing at all when null (reproducing an empty/garbled - /// provider response so the deterministic fallback path can be exercised). - /// - private sealed class ScriptedCurationChatClient(string? responseText) : IChatClient - { - public Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => Task.FromResult(new ChatResponse(new AiChatMessage(AiChatRole.Assistant, responseText ?? string.Empty))); - - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => StreamAsync(cancellationToken); - - private async IAsyncEnumerable StreamAsync([EnumeratorCancellation] CancellationToken cancellationToken) - { - if (responseText is not null) - yield return new ChatResponseUpdate(AiChatRole.Assistant, responseText); - - await Task.CompletedTask; - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() - { - } - } } diff --git a/src/Netclaw.Actors.Tests/Sessions/SidecarSessionCorrelationTests.cs b/src/Netclaw.Actors.Tests/Sessions/SidecarSessionCorrelationTests.cs index 075443a11..ff4789b2c 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SidecarSessionCorrelationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SidecarSessionCorrelationTests.cs @@ -3,7 +3,6 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- -using System.Runtime.CompilerServices; using Akka.Event; using Akka.Hosting.TestKit; using Microsoft.Extensions.AI; @@ -13,8 +12,6 @@ using Netclaw.Actors.Sessions.Pipelines; using Netclaw.Configuration; using Xunit; -using AiChatMessage = Microsoft.Extensions.AI.ChatMessage; -using AiChatRole = Microsoft.Extensions.AI.ChatRole; namespace Netclaw.Actors.Tests.Sessions; @@ -39,7 +36,7 @@ protected override void ConfigureAkka(Akka.Hosting.AkkaConfigurationBuilder buil public async Task TitleGenerator_carries_session_scoped_options() { var sessionId = new SessionId("ch/title-thread"); - var captor = new OptionsCapturingChatClient(); + var captor = new FakeChatClient(); await SessionTitleGenerator.GenerateAsync( captor, sessionId, history: [], self: CreateTestProbe().Ref, @@ -52,7 +49,7 @@ await SessionTitleGenerator.GenerateAsync( public async Task CompactionObserver_carries_session_scoped_options() { var sessionId = new SessionId("ch/compaction-thread"); - var captor = new OptionsCapturingChatClient(); + var captor = new FakeChatClient(); var history = new List { new() { Role = Netclaw.Actors.Protocol.ChatRole.User, Content = "hello" }, @@ -71,7 +68,7 @@ await SessionCompactionPipeline.GenerateObservationsAsync( public async Task MemoryExtraction_carries_session_scoped_options() { var sessionId = new SessionId("ch/memory-extraction-thread"); - var captor = new OptionsCapturingChatClient(); + var captor = new FakeChatClient(); await LlmSessionActor.InvokeMemoryExtractionCoreAsync( captor, sessionId, history: [], self: CreateTestProbe().Ref, timeout: TimeSpan.FromSeconds(5)); @@ -83,7 +80,7 @@ await LlmSessionActor.InvokeMemoryExtractionCoreAsync( public async Task MemoryDistillation_carries_session_scoped_options() { var sessionId = new SessionId("ch/distillation-thread"); - var captor = new OptionsCapturingChatClient(); + var captor = new FakeChatClient(); await SessionMemoryObserverActor.RunDistillationAsync( client: captor, sessionId: sessionId, turnCount: 5, @@ -98,7 +95,7 @@ await SessionMemoryObserverActor.RunDistillationAsync( public async Task MemoryCuration_carries_session_scoped_options() { var sessionId = new SessionId("ch/curation-thread"); - var captor = new OptionsCapturingChatClient(); + var captor = new FakeChatClient(); var operation = new SQLiteMemoryCurationOperation( Kind: "document", MemoryClass: "durable_fact", @@ -126,48 +123,12 @@ await MemoryCurationEvaluator.TryLlmEvaluationAsync( AssertScopedTo(sessionId, captor); } - private static void AssertScopedTo(SessionId sessionId, OptionsCapturingChatClient captor) + private static void AssertScopedTo(SessionId sessionId, FakeChatClient captor) { - var scoped = Assert.IsType(captor.CapturedOptions); + // FakeChatClient here is the Sessions-namespace fake (it is purpose-built for + // these pipeline paths); it records the options of every call, so the last entry + // is the most recent invocation the decorators scoped. + var scoped = Assert.IsType(captor.ReceivedOptions[^1]); Assert.Equal(sessionId.Value, scoped.SessionId); } - - /// - /// IChatClient stub that records the it is invoked with — the - /// object the chat-client decorators read the session id from to open the routing scope. - /// - private sealed class OptionsCapturingChatClient : IChatClient - { - public ChatOptions? CapturedOptions { get; private set; } - - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - CapturedOptions = options; - return Task.FromResult(new ChatResponse(new AiChatMessage( - AiChatRole.Assistant, (IList)[new TextContent("captured")]))); - } - - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - CapturedOptions = options; - return StreamAsync(cancellationToken); - } - - private static async IAsyncEnumerable StreamAsync( - [EnumeratorCancellation] CancellationToken cancellationToken) - { - yield return new ChatResponseUpdate(AiChatRole.Assistant, "captured"); - await Task.CompletedTask; - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } } diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 0a6ffd8f2..91dc7a58b 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -19,6 +19,7 @@ using Netclaw.Configuration; using Netclaw.Security; using Netclaw.Tests.Utilities; +using FakeChatClient = Netclaw.Tests.Utilities.FakeChatClient; using Netclaw.Tools; using Xunit; using static Netclaw.Actors.SubAgents.SubAgentProtocol; @@ -1175,7 +1176,7 @@ public void Classify_distinguishes_keepalives_from_substantive_progress() [Fact] public async Task LLM_failure_returns_failure() { - var throwingClient = new ThrowingChatClient(); + var throwingClient = new FakeChatClient { Failure = new InvalidOperationException("LLM connection failed") }; var definition = CreateDefinition(); var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, throwingClient)); @@ -1355,133 +1356,11 @@ public async Task Null_RuntimeContext_leaves_first_user_message_as_raw_task() Assert.DoesNotContain("Context:", fakeClient.LastReceivedMessages[1].Text); } - /// - /// IChatClient that always throws on GetResponseAsync. - /// - private sealed class ThrowingChatClient : IChatClient - { - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - => throw new InvalidOperationException("LLM connection failed"); - - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - => throw new InvalidOperationException("LLM connection failed"); - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - public void Dispose() { } - } - // Real PNG: the egress normalizer decodes every model-input image, so a // fake magic-byte stub would now be dropped. Small enough to pass through. private static readonly byte[] FakePngBytes = TestImages.SmallPng(); } -/// -/// Fake IChatClient for SubAgentActor tests (and other test files that need it). -/// Copied from LlmSessionIntegrationTests — kept internal for cross-file reuse. -/// -internal sealed class FakeChatClient : IChatClient -{ - private int _callCount; - - public int CallCount => _callCount; - - /// - /// Snapshot of the messages passed to the most recent call. Replaced on every call. - /// - public IReadOnlyList? LastReceivedMessages { get; private set; } - - public TimeSpan Delay { get; set; } = TimeSpan.Zero; - - /// - /// When set, the first response returns these tool calls instead of text. - /// Subsequent calls return normal text (simulating the LLM completing after tool results). - /// When is true, every call returns tool calls - /// as long as tools are available in options. - /// - public List? ToolCallsOnFirstCall { get; set; } - - /// - /// When true, every call returns tool calls as long as options.Tools is non-empty. - /// - public bool AlwaysReturnToolCalls { get; set; } - - public string? ResponseText { get; set; } - - public IReadOnlyList? ResponseTextsByCall { get; set; } - - /// - /// When set, every returned response carries these token counts as - /// . The streaming reader coalesces that back into - /// response.Usage, so a test can prove the sub-agent bills each LLM call's - /// tokens to . - /// - public UsageDetails? UsageOverride { get; set; } - - public async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref _callCount); - LastReceivedMessages = messages.ToList(); - - if (Delay > TimeSpan.Zero) - await Task.Delay(Delay, cancellationToken); - - if (ToolCallsOnFirstCall is not null) - { - var returnToolCalls = AlwaysReturnToolCalls - ? options?.Tools?.Count > 0 - : _callCount == 1; - - if (returnToolCalls) - { - var toolCallContents = new List(ToolCallsOnFirstCall); - var toolCallMessage = new ChatMessage( - ChatRole.Assistant, toolCallContents); - return new ChatResponse(toolCallMessage) { Usage = UsageOverride }; - } - } - - var responseText = ResponseTextsByCall is { Count: > 0 } responses && _callCount <= responses.Count - ? responses[_callCount - 1] - : ResponseText ?? $"[fake] Response #{_callCount}"; - - var responseMessage = new ChatMessage( - ChatRole.Assistant, - [new TextContent(responseText)]); - return new ChatResponse(responseMessage) { Usage = UsageOverride }; - } - - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - => CreateStreamingUpdatesAsync(messages, options, cancellationToken); - - private async IAsyncEnumerable CreateStreamingUpdatesAsync( - IEnumerable messages, - ChatOptions? options, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) - { - var response = await GetResponseAsync(messages, options, cancellationToken); - foreach (var update in response.ToChatResponseUpdates()) - { - cancellationToken.ThrowIfCancellationRequested(); - yield return update; - } - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - public void Dispose() { } -} - /// /// Streaming-only fake that emits no updates and parks until the consumer cancels /// (i.e. the sub-agent's watchdog fires). No Task.Delay: the only timing is diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentObservabilityTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentObservabilityTests.cs index c37161dfc..59660f646 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentObservabilityTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentObservabilityTests.cs @@ -10,6 +10,7 @@ using Netclaw.Actors.SubAgents; using Netclaw.Actors.Tests.Memory; using Netclaw.Configuration; +using Netclaw.Tests.Utilities; using Netclaw.Tools; using Xunit; using static Netclaw.Actors.SubAgents.SubAgentProtocol; diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs index cca1518b6..64062c599 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs @@ -13,6 +13,7 @@ using Netclaw.Actors.Tools; using Netclaw.Configuration; using Netclaw.Security; +using Netclaw.Tests.Utilities; using Netclaw.Tools; using Xunit; using static Netclaw.Actors.SubAgents.SubAgentProtocol; @@ -35,7 +36,7 @@ public async Task Spawn_async_propagates_parent_resolved_cwd_on_run_message() toolRegistry.Register(new FakeNetclawTool("inspect_context", "ok")); var spawner = new SubAgentSpawner( - new SingleClientProvider(new NoOpChatClient()), + new SingleClientProvider(new FakeChatClient()), toolRegistry, new ToolAccessPolicy( new ToolConfig(), @@ -96,7 +97,7 @@ public async Task Spawn_async_ignores_definition_tool_metadata_for_runtime_tool_ toolRegistry.Register(new FakeNetclawTool("inspect_context", "ok")); var spawner = new SubAgentSpawner( - new SingleClientProvider(new NoOpChatClient()), + new SingleClientProvider(new FakeChatClient()), toolRegistry, new ToolAccessPolicy( new ToolConfig(), @@ -212,28 +213,4 @@ public async Task Spawned_sub_agent_bills_its_llm_calls_to_session_metrics() var call = Assert.Single(metrics.TokenUsageCalls); Assert.Equal((175L, 60L), call); } - - private sealed class NoOpChatClient : IChatClient - { - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - => Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "noop"))); - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.CompletedTask; - yield break; - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() - { - } - } } diff --git a/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs b/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs index ca81c0945..da0985869 100644 --- a/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs @@ -1114,32 +1114,8 @@ public void RecordSkillLoaded(string skillName, SkillLoadMethod method) private sealed class NoOpChatClientProvider : IChatClientProvider { - private readonly IChatClient _client = new NoOpChatClient(); + private readonly IChatClient _client = new FakeChatClient(); public IChatClient GetClient(ModelRole role) => _client; } - - private sealed class NoOpChatClient : IChatClient - { - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - => Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "noop"))); - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.CompletedTask; - yield break; - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() - { - } - } } diff --git a/src/Netclaw.Tests.Utilities/FakeChatClient.cs b/src/Netclaw.Tests.Utilities/FakeChatClient.cs new file mode 100644 index 000000000..25b73abac --- /dev/null +++ b/src/Netclaw.Tests.Utilities/FakeChatClient.cs @@ -0,0 +1,134 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.AI; + +namespace Netclaw.Tests.Utilities; + +/// +/// A configurable, thread-safe fake for tests that need to +/// capture what was sent and/or script what comes back. It covers the "capture and/or +/// return canned output" needs that were previously spread across a dozen near-identical +/// one-off fakes (option/message/image/context capturers, scripted-response fakes, +/// no-ops, and the sub-agent's FakeChatClient copy). +/// +/// Streaming is intentionally trivial: it runs and replays +/// the result via , which also +/// surfaces as a streamed UsageContent. Tests that +/// assert on a *specific streaming shape* (parked/hanging/gated streams, mid-stream delta +/// timing, or a synchronous throw whose timing is load-bearing) are deliberately NOT served +/// by this type — those keep their own small bespoke fakes, because the stream shape is the +/// thing under test and a flag on a general fake would read worse, not better. +/// +public sealed class FakeChatClient : IChatClient +{ + private readonly object _gate = new(); + private readonly List> _receivedMessagesByCall = []; + private int _callCount; + + /// Number of times a response has been requested (either path). + public int CallCount => Volatile.Read(ref _callCount); + + /// Snapshot of the messages passed to the most recent call. + public IReadOnlyList? LastReceivedMessages { get; private set; } + + /// The passed to the most recent call. + public ChatOptions? LastReceivedOptions { get; private set; } + + /// Snapshot of the messages passed to every call, in order. + public IReadOnlyList> ReceivedMessagesByCall + { + get { lock (_gate) { return _receivedMessagesByCall.ToArray(); } } + } + + /// When > 0, the response is delayed by this amount (success path only). + public TimeSpan Delay { get; set; } = TimeSpan.Zero; + + /// When set, every call throws this exception instead of returning a response. + public Exception? Failure { get; set; } + + /// Default response text when no per-call text applies. Defaults to a marker. + public string? ResponseText { get; set; } + + /// Per-call response text, indexed by (1-based) call number. + public IReadOnlyList? ResponseTextsByCall { get; set; } + + /// + /// When set, a qualifying call returns these tool calls instead of text. + /// By default only the first call qualifies; see . + /// + public List? ToolCallsOnFirstCall { get; set; } + + /// When true, every call with tools available returns the tool calls. + public bool AlwaysReturnToolCalls { get; set; } + + /// When set, every returned response carries these token counts as . + public UsageDetails? UsageOverride { get; set; } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _callCount); + var snapshot = messages.ToList(); + lock (_gate) + { + LastReceivedMessages = snapshot; + LastReceivedOptions = options; + _receivedMessagesByCall.Add(snapshot); + } + + if (Failure is not null) + throw Failure; + + if (Delay > TimeSpan.Zero) + await Task.Delay(Delay, cancellationToken); + + if (ToolCallsOnFirstCall is not null) + { + var returnToolCalls = AlwaysReturnToolCalls + ? options?.Tools?.Count > 0 + : CallCount == 1; + + if (returnToolCalls) + { + var toolCallMessage = new ChatMessage( + ChatRole.Assistant, new List(ToolCallsOnFirstCall)); + return new ChatResponse(toolCallMessage) { Usage = UsageOverride }; + } + } + + var responseText = ResponseTextsByCall is { Count: > 0 } responses && CallCount <= responses.Count + ? responses[CallCount - 1] + : ResponseText ?? $"[fake] Response #{CallCount}"; + + var responseMessage = new ChatMessage(ChatRole.Assistant, [new TextContent(responseText)]); + return new ChatResponse(responseMessage) { Usage = UsageOverride }; + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + => CreateStreamingUpdatesAsync(messages, options, cancellationToken); + + private async IAsyncEnumerable CreateStreamingUpdatesAsync( + IEnumerable messages, + ChatOptions? options, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + var response = await GetResponseAsync(messages, options, cancellationToken); + foreach (var update in response.ToChatResponseUpdates()) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } +} diff --git a/src/Netclaw.Tests.Utilities/Netclaw.Tests.Utilities.csproj b/src/Netclaw.Tests.Utilities/Netclaw.Tests.Utilities.csproj index 80ad8e65c..32df96829 100644 --- a/src/Netclaw.Tests.Utilities/Netclaw.Tests.Utilities.csproj +++ b/src/Netclaw.Tests.Utilities/Netclaw.Tests.Utilities.csproj @@ -9,6 +9,7 @@ +