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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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<DataContent>().Any(),
"Expected LLM to receive DataContent (image) in chat messages");

var sessionId = new SessionId("ch-1/msg-1000");
Expand Down Expand Up @@ -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<DataContent>().Any(),
"Expected LLM to receive image content from attachment-only message");
}

Expand Down Expand Up @@ -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<DataContent>().Any(),
"Expected LLM not to receive image when scanner fails");

Assert.Contains(_replyClient.Posts,
Expand Down Expand Up @@ -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<DataContent>().Any(),
"Expected LLM to receive DataContent (image) via real MagicByteContentScanner");
}

Expand Down Expand Up @@ -349,58 +358,6 @@ protected override Task<HttpResponseMessage> SendAsync(
}
}

private sealed class ImageCapturingChatClient : IChatClient
{
private int _callCount;
public int CallCount => _callCount;
public volatile bool ReceivedImageContent;

public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _callCount);

foreach (var msg in messages)
{
if (msg.Contents.OfType<DataContent>().Any())
ReceivedImageContent = true;
}

var contents = new List<AIContent>
{
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<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
=> CreateStreamingAsync(messages, options, cancellationToken);

private async IAsyncEnumerable<ChatResponseUpdate> CreateStreamingAsync(
IEnumerable<ChatMessage> 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<ResolvedModelCapabilities?> ResolveAsync(
Expand Down
79 changes: 22 additions & 57 deletions src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -61,6 +62,17 @@ public SlackAttachmentIngressVisionTests(ITestOutputHelper output) : base(output
_paths.EnsureDirectoriesExist();
}

/// <summary>
/// Contents of every USER-role message across all LLM calls so far, in
/// call order — mirrors the old <c>RecordingChatClient.ReceivedMessages</c>
/// accumulator that this test suite asserted against.
/// </summary>
private IReadOnlyList<IList<AIContent>> 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<IChatClientProvider>(new SingleClientProvider(_chatClient));
Expand Down Expand Up @@ -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<TextContent>()
.First(t => t.Text.Contains("[attachment]", StringComparison.Ordinal)
Expand All @@ -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<DataContent>()
.Where(d => d.MediaType == "application/pdf");
Expand Down Expand Up @@ -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<TextContent>()
.First(t => t.Text.Contains("[attachment]", StringComparison.Ordinal)
Expand All @@ -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<DataContent>()
.Where(d => d.MediaType?.Contains("wordprocessingml", StringComparison.Ordinal) == true);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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<IList<AIContent>> _messages = [];

public IReadOnlyList<IList<AIContent>> ReceivedMessages
{
get
{
lock (_gate)
return _messages.ToList();
}
}

public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> 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<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> 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<ContentScanResult> ScanAsync(
Expand Down
Loading
Loading