From 8c8a59a597bf459024e969e5ede67d9a5e0cea52 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 11 Jul 2026 14:32:59 -0500 Subject: [PATCH 1/9] fix: support Discord DM reminders (#1609) --- docs/integrations/discord-channel.md | 15 ++- .../.system/files/netclaw-operations/SKILL.md | 2 +- .../references/scheduling.md | 4 + .../DiscordReminderTargetResolverTests.cs | 79 ++++++++++++--- .../Reminders/SetReminderToolTests.cs | 95 +++++++++++++++++-- .../Reminders/SetReminderTool.cs | 9 +- .../DiscordReminderTargetResolver.cs | 89 +++++++++++++---- ...hannelIntegrationRegistrationExtensions.cs | 2 +- .../Configuration/RemoteChatChannelBuilder.cs | 11 +++ 9 files changed, 251 insertions(+), 55 deletions(-) diff --git a/docs/integrations/discord-channel.md b/docs/integrations/discord-channel.md index 54aaebc90..9e2a2fef2 100644 --- a/docs/integrations/discord-channel.md +++ b/docs/integrations/discord-channel.md @@ -118,9 +118,20 @@ Discord channel target resolution accepts canonical channel forms: - `<#123...>` (channel mention) - `channel:123...` (explicit channel ID) +Discord direct-message target resolution accepts explicit user forms: + +- `<@123...>` or `<@!123...>` (user mention) +- `dm:123...` or `@123...` (explicit user ID) + +Bare Discord snowflakes are rejected because channel IDs and user IDs have the +same shape. Use `channel:` for channel delivery or `dm:` for +direct-message delivery. + Reminder channel delivery maps to the generic `send_channel_message` tool with -`channel_key = "discord"` and a resolved destination object. Discord proactive -DM output is not supported yet. +`channel_key = "discord"` and a resolved destination object. Channel targets use +`destination.kind = "destination"`; user targets use +`destination.kind = "direct_message"` and are still gated by +`AllowDirectMessages` and `AllowedUserIds` at send time. ## Runtime behavior and troubleshooting diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index a514dbd60..4980ff49e 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.24.0" + version: "2.24.1" --- # Netclaw Operations diff --git a/feeds/skills/.system/files/netclaw-operations/references/scheduling.md b/feeds/skills/.system/files/netclaw-operations/references/scheduling.md index 097c95d05..cf65fd583 100644 --- a/feeds/skills/.system/files/netclaw-operations/references/scheduling.md +++ b/feeds/skills/.system/files/netclaw-operations/references/scheduling.md @@ -33,6 +33,10 @@ Rules: conversational follow-ups in Slack/TUI/SignalR sessions. - `channel` requires both transport + address and resolves names/handles to canonical IDs at set time; unresolved targets fail loud. +- Discord reminder targets must be explicit because channel IDs and user IDs are + both snowflakes: use `channel:` or `<#channelId>` for channel posts, + and `dm:`, `@`, or `<@userId>` for DMs. Do not pass a bare + Discord ID. - `none` runs silently (history still records execution). - `expires_in` is not valid for `once` reminders; omit it for one-shot schedules. - For recurring reminders that are permanently complete (PR merged, deploy done, diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordReminderTargetResolverTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordReminderTargetResolverTests.cs index b2c0bdc9b..a14d88849 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordReminderTargetResolverTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordReminderTargetResolverTests.cs @@ -11,30 +11,49 @@ namespace Netclaw.Actors.Tests.Channels; public sealed class DiscordReminderTargetResolverTests { - private readonly DiscordReminderTargetResolver _resolver = new(); + private const string ChannelId = "129847561203948576"; + private const string UserId = "130111223344556677"; + + private readonly DiscordReminderTargetResolver _resolver = new(new DiscordChannelOptions + { + AllowDirectMessages = true, + AllowedChannelIds = [ChannelId], + AllowedUserIds = [UserId] + }); [Fact] public async Task Resolves_channel_mention_to_canonical_channel_id() { - var result = await _resolver.ResolveAsync("<#129847561203948576>", TestContext.Current.CancellationToken); + var result = await _resolver.ResolveAsync($"<#{ChannelId}>", TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(ReminderTargetKind.Channel, result.Kind); - Assert.Equal("129847561203948576", result.ResolvedId); + Assert.Equal(ChannelId, result.ResolvedId); Assert.Null(result.ErrorMessage); } [Fact] public async Task Resolves_explicit_channel_prefix_to_canonical_channel_id() { - var result = await _resolver.ResolveAsync("channel:129847561203948576", TestContext.Current.CancellationToken); + var result = await _resolver.ResolveAsync($"channel:{ChannelId}", TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(ReminderTargetKind.Channel, result.Kind); - Assert.Equal("129847561203948576", result.ResolvedId); + Assert.Equal(ChannelId, result.ResolvedId); Assert.Null(result.ErrorMessage); } + [Fact] + public async Task Rejects_channel_targets_outside_allowed_channels() + { + var result = await _resolver.ResolveAsync("channel:129847561203948577", TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Equal(ReminderTargetKind.Unknown, result.Kind); + Assert.Null(result.ResolvedId); + Assert.Contains("allowed channels", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Rejects_bare_snowflakes_because_they_are_ambiguous() { @@ -50,26 +69,60 @@ public async Task Rejects_bare_snowflakes_because_they_are_ambiguous() [InlineData("@129847561203948576")] [InlineData("<@129847561203948576>")] [InlineData("<@!129847561203948576>")] - public async Task Rejects_user_targets_while_discord_reminders_are_channel_only(string input) + [InlineData("dm:129847561203948576")] + public async Task Resolves_explicit_user_targets_to_canonical_user_id(string input) + { + var resolver = new DiscordReminderTargetResolver(new DiscordChannelOptions + { + AllowDirectMessages = true, + AllowedUserIds = ["129847561203948576"] + }); + + var result = await resolver.ResolveAsync(input, TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(ReminderTargetKind.User, result.Kind); + Assert.Equal("129847561203948576", result.ResolvedId); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task Rejects_user_targets_when_direct_messages_are_disabled() + { + var resolver = new DiscordReminderTargetResolver(new DiscordChannelOptions + { + AllowDirectMessages = false, + AllowedUserIds = [UserId] + }); + + var result = await resolver.ResolveAsync($"dm:{UserId}", TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Equal(ReminderTargetKind.Unknown, result.Kind); + Assert.Null(result.ResolvedId); + Assert.Contains("disabled", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Rejects_user_targets_outside_allowed_users() { - var result = await _resolver.ResolveAsync(input, TestContext.Current.CancellationToken); + var result = await _resolver.ResolveAsync("dm:130111223344556678", TestContext.Current.CancellationToken); Assert.False(result.Success); Assert.Equal(ReminderTargetKind.Unknown, result.Kind); Assert.Null(result.ResolvedId); - Assert.Contains("channel", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - Assert.Contains("not supported", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("allowed users", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Rejects_dm_channel_prefix_while_discord_reminders_are_channel_only() + public async Task Rejects_invalid_direct_message_target() { - var result = await _resolver.ResolveAsync("dm:130111223344556677", TestContext.Current.CancellationToken); + var result = await _resolver.ResolveAsync("dm:not-a-user-id", TestContext.Current.CancellationToken); Assert.False(result.Success); Assert.Equal(ReminderTargetKind.Unknown, result.Kind); Assert.Null(result.ResolvedId); - Assert.Contains("DM", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("direct-message", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -80,6 +133,6 @@ public async Task Rejects_non_canonical_discord_targets() Assert.False(result.Success); Assert.Equal(ReminderTargetKind.Unknown, result.Kind); Assert.Null(result.ResolvedId); - Assert.Contains("channel:", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("user target", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); } } diff --git a/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs b/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs index 14123f8ad..87af6b1a5 100644 --- a/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs +++ b/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs @@ -12,6 +12,7 @@ using Netclaw.Actors.Channels; using Netclaw.Actors.Reminders; using Netclaw.Actors.Tests.Hosting; +using Netclaw.Channels.Discord; using Netclaw.Configuration; using Netclaw.Tools; using Xunit; @@ -810,7 +811,7 @@ public async Task Mode_B_session_reentry_skips_resolver() } [Fact] - public async Task Resolves_user_target_to_dm_notify_instructions() + public async Task Resolves_user_target_to_direct_message_delivery_target() { var probe = CreateTestProbe(); var resolver = new TestResolver @@ -839,6 +840,10 @@ public async Task Resolves_user_target_to_dm_notify_instructions() var cmd = await probe.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(DeliveryKind.Channel, cmd.Definition.Delivery.Kind); Assert.Equal("U0456XYZ", cmd.Definition.Delivery.Address); + Assert.NotNull(cmd.Definition.Delivery.Target); + Assert.Equal("slack", cmd.Definition.Delivery.Target!.ChannelKey); + Assert.Equal("direct_message", cmd.Definition.Delivery.Target.DestinationKind); + Assert.Equal("U0456XYZ", cmd.Definition.Delivery.Target.DestinationId); Assert.Equal(1, resolver.CallCount); probe.Reply(new ReminderSavedResponse( @@ -851,7 +856,7 @@ public async Task Resolves_user_target_to_dm_notify_instructions() } [Fact] - public async Task Rejects_discord_user_target_with_discord_transport() + public async Task Resolves_discord_user_target_to_direct_message_delivery_target() { var probe = CreateTestProbe(); var resolver = new TestResolver @@ -863,22 +868,94 @@ public async Task Rejects_discord_user_target_with_discord_transport() }; var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig(), [resolver]); + var execution = Task.Run(async () => + { + return await tool.ExecuteAsync(new Dictionary + { + ["Id"] = "discord-user-target", + ["Name"] = "discord-user-target", + ["Prompt"] = "Send results", + ["ScheduleType"] = "once", + ["Schedule"] = "15m", + ["DeliveryKind"] = "channel", + ["DeliveryTransport"] = "discord", + ["DeliveryAddress"] = "<@129847561203948576>" + }, TestContext.Current.CancellationToken); + }, TestContext.Current.CancellationToken); + + var cmd = await probe.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(DeliveryKind.Channel, cmd.Definition.Delivery.Kind); + Assert.Equal("discord", cmd.Definition.Delivery.Transport); + Assert.Equal("129847561203948576", cmd.Definition.Delivery.Address); + Assert.NotNull(cmd.Definition.Delivery.Target); + Assert.Equal("discord", cmd.Definition.Delivery.Target!.ChannelKey); + Assert.Equal("direct_message", cmd.Definition.Delivery.Target.DestinationKind); + Assert.Equal("129847561203948576", cmd.Definition.Delivery.Target.DestinationId); + Assert.Equal("129847561203948576", cmd.Definition.Delivery.Target.DestinationDisplayName); + Assert.Equal(1, resolver.CallCount); + + probe.Reply(new ReminderSavedResponse( + cmd.Definition.Id, + cmd.Definition.Title, + Success: true, + NextFire: _timeProvider.GetUtcNow().AddMinutes(15))); + + var result = await execution; + Assert.StartsWith("Reminder 'discord-user-target' scheduled.", result); + } + + [Fact] + public async Task Rejects_discord_direct_message_reminder_when_direct_messages_are_disabled() + { + var probe = CreateTestProbe(); + var resolver = new DiscordReminderTargetResolver(new DiscordChannelOptions + { + AllowDirectMessages = false + }); + var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig(), [resolver]); + var result = await tool.ExecuteAsync(new Dictionary { - ["Id"] = "discord-user-target", - ["Name"] = "discord-user-target", + ["Id"] = "discord-dm-disabled", + ["Name"] = "discord-dm-disabled", ["Prompt"] = "Send results", ["ScheduleType"] = "once", ["Schedule"] = "15m", ["DeliveryKind"] = "channel", ["DeliveryTransport"] = "discord", - ["DeliveryAddress"] = "<@129847561203948576>" + ["DeliveryAddress"] = "dm:129847561203948576" }, TestContext.Current.CancellationToken); - Assert.StartsWith("Error:", result); - Assert.Contains("guild text-channel targets only", result, StringComparison.OrdinalIgnoreCase); - Assert.Contains("channel:", result, StringComparison.OrdinalIgnoreCase); - Assert.Equal(1, resolver.CallCount); + Assert.StartsWith("Error: Could not resolve delivery_address", result); + Assert.Contains("direct messages are disabled", result, StringComparison.OrdinalIgnoreCase); + await probe.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Rejects_discord_direct_message_reminder_when_user_is_not_allowlisted() + { + var probe = CreateTestProbe(); + var resolver = new DiscordReminderTargetResolver(new DiscordChannelOptions + { + AllowDirectMessages = true, + AllowedUserIds = ["130111223344556677"] + }); + var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig(), [resolver]); + + var result = await tool.ExecuteAsync(new Dictionary + { + ["Id"] = "discord-dm-disallowed", + ["Name"] = "discord-dm-disallowed", + ["Prompt"] = "Send results", + ["ScheduleType"] = "once", + ["Schedule"] = "15m", + ["DeliveryKind"] = "channel", + ["DeliveryTransport"] = "discord", + ["DeliveryAddress"] = "dm:129847561203948576" + }, TestContext.Current.CancellationToken); + + Assert.StartsWith("Error: Could not resolve delivery_address", result); + Assert.Contains("allowed users", result, StringComparison.OrdinalIgnoreCase); await probe.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } diff --git a/src/Netclaw.Actors/Reminders/SetReminderTool.cs b/src/Netclaw.Actors/Reminders/SetReminderTool.cs index 1b199f0e3..5c1866b9b 100644 --- a/src/Netclaw.Actors/Reminders/SetReminderTool.cs +++ b/src/Netclaw.Actors/Reminders/SetReminderTool.cs @@ -174,7 +174,7 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon { var detail = resolution.ErrorMessage ?? "unresolvable target"; var hint = string.Equals(transport, "discord", StringComparison.OrdinalIgnoreCase) - ? "Use channel: or <#channelId>." + ? "Use channel:, <#channelId>, dm:, or <@userId>." : "Use #channel, @user, or a valid channel ID."; return $"Error: Could not resolve delivery_address '{rawDeliveryAddress}': {detail}. {hint}"; } @@ -182,13 +182,6 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon if (string.IsNullOrWhiteSpace(resolution.ResolvedId)) return $"Error: Could not resolve delivery_address '{rawDeliveryAddress}': resolver returned an empty canonical target ID."; - if (string.Equals(transport, "discord", StringComparison.OrdinalIgnoreCase) - && resolution.Kind is not ReminderTargetKind.Channel) - { - return "Error: Discord channel delivery currently supports guild text-channel targets only. " - + "Use channel: or <#channelId>; user and DM targets are not supported yet."; - } - delivery = new ReminderDelivery { Kind = DeliveryKind.Channel, diff --git a/src/Netclaw.Channels.Discord/DiscordReminderTargetResolver.cs b/src/Netclaw.Channels.Discord/DiscordReminderTargetResolver.cs index 99dd2b776..d2394bb9b 100644 --- a/src/Netclaw.Channels.Discord/DiscordReminderTargetResolver.cs +++ b/src/Netclaw.Channels.Discord/DiscordReminderTargetResolver.cs @@ -9,14 +9,14 @@ namespace Netclaw.Channels.Discord; /// -/// Resolves Discord reminder targets to canonical guild text-channel IDs. -/// Current reminder delivery is channel-only: proactive DM delivery is deferred -/// until Discord gains a session model that can preserve thread context. +/// Resolves Discord reminder targets to canonical guild text-channel or user IDs. /// Supported inputs: /// - channel mention: <#123...> /// - explicit channel ID: channel:123... +/// - user mention: <@123...> or <@!123...> +/// - explicit user ID: dm:123... or @123... /// -public sealed class DiscordReminderTargetResolver : IReminderTargetResolver +public sealed class DiscordReminderTargetResolver(DiscordChannelOptions options) : IReminderTargetResolver { private static readonly Regex MentionRegex = new("^<@!?([0-9]{17,20})>$", RegexOptions.Compiled | RegexOptions.CultureInvariant); @@ -44,24 +44,22 @@ public Task ResolveAsync(string target, CancellationTo if (raw.StartsWith("dm:", StringComparison.OrdinalIgnoreCase)) { + var userId = raw[3..].Trim(); + if (SnowflakeRegex.IsMatch(userId)) + return Task.FromResult(ResolveUser(userId)); + return Task.FromResult(new ReminderTargetResolution( Success: false, ResolvedId: null, Kind: ReminderTargetKind.Unknown, - ErrorMessage: "Discord reminder delivery currently supports guild text channels only; DM targets are not supported. Use channel: or <#channelId>.")); + ErrorMessage: "Invalid Discord direct-message target. Use dm:, @, or <@userId>.")); } if (raw.StartsWith("channel:", StringComparison.OrdinalIgnoreCase)) { var channelId = raw[8..].Trim(); if (SnowflakeRegex.IsMatch(channelId)) - { - return Task.FromResult(new ReminderTargetResolution( - Success: true, - ResolvedId: channelId, - Kind: ReminderTargetKind.Channel, - ErrorMessage: null)); - } + return Task.FromResult(ResolveChannel(channelId)); return Task.FromResult(new ReminderTargetResolution( Success: false, @@ -73,11 +71,7 @@ public Task ResolveAsync(string target, CancellationTo var channelMention = ChannelMentionRegex.Match(raw); if (channelMention.Success) { - return Task.FromResult(new ReminderTargetResolution( - Success: true, - ResolvedId: channelMention.Groups[1].Value, - Kind: ReminderTargetKind.Channel, - ErrorMessage: null)); + return Task.FromResult(ResolveChannel(channelMention.Groups[1].Value)); } if (SnowflakeRegex.IsMatch(raw)) @@ -86,23 +80,76 @@ public Task ResolveAsync(string target, CancellationTo Success: false, ResolvedId: null, Kind: ReminderTargetKind.Unknown, - ErrorMessage: "Bare Discord snowflakes are ambiguous. Use channel: or <#channelId> for channel delivery.")); + ErrorMessage: "Bare Discord snowflakes are ambiguous. Use channel: or <#channelId> for channel delivery, or dm: / <@userId> for direct-message delivery.")); } var mention = MentionRegex.Match(raw); - if (mention.Success || raw.StartsWith("@", StringComparison.Ordinal)) + if (mention.Success) { + return Task.FromResult(ResolveUser(mention.Groups[1].Value)); + } + + if (raw.StartsWith("@", StringComparison.Ordinal)) + { + var userId = raw[1..].Trim(); + if (SnowflakeRegex.IsMatch(userId)) + return Task.FromResult(ResolveUser(userId)); + return Task.FromResult(new ReminderTargetResolution( Success: false, ResolvedId: null, Kind: ReminderTargetKind.Unknown, - ErrorMessage: "Discord reminder delivery currently supports guild text channels only; user/DM targets are not supported. Use channel: or <#channelId>.")); + ErrorMessage: "Invalid Discord user target. Use @, <@userId>, or dm:.")); } return Task.FromResult(new ReminderTargetResolution( Success: false, ResolvedId: null, Kind: ReminderTargetKind.Unknown, - ErrorMessage: $"Could not resolve Discord target '{target}'. Use channel: or <#channelId>.")); + ErrorMessage: $"Could not resolve Discord target '{target}'. Use channel:, <#channelId>, dm:, or <@userId>.")); + } + + private ReminderTargetResolution ResolveChannel(string channelId) + { + return DiscordAclPolicy.IsAllowedChannel(new DiscordChannelId(channelId), options, GetDefaultChannelId()) + ? new ReminderTargetResolution( + Success: true, + ResolvedId: channelId, + Kind: ReminderTargetKind.Channel, + ErrorMessage: null) + : new ReminderTargetResolution( + Success: false, + ResolvedId: null, + Kind: ReminderTargetKind.Unknown, + ErrorMessage: $"Discord channel '{channelId}' is not in the allowed channels list."); } + + private ReminderTargetResolution ResolveUser(string userId) + { + if (!options.AllowDirectMessages) + { + return new ReminderTargetResolution( + Success: false, + ResolvedId: null, + Kind: ReminderTargetKind.Unknown, + ErrorMessage: "Discord direct messages are disabled in configuration."); + } + + return DiscordAclPolicy.IsAllowedUser(new DiscordUserId(userId), options) + ? new ReminderTargetResolution( + Success: true, + ResolvedId: userId, + Kind: ReminderTargetKind.User, + ErrorMessage: null) + : new ReminderTargetResolution( + Success: false, + ResolvedId: null, + Kind: ReminderTargetKind.Unknown, + ErrorMessage: $"Discord user '{userId}' is not in the allowed users list."); + } + + private DiscordChannelId? GetDefaultChannelId() + => string.IsNullOrWhiteSpace(options.DefaultChannelId) + ? null + : new DiscordChannelId(options.DefaultChannelId); } diff --git a/src/Netclaw.Daemon/Configuration/ChannelIntegrationRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/ChannelIntegrationRegistrationExtensions.cs index 1b8f8a530..882ad9bc1 100644 --- a/src/Netclaw.Daemon/Configuration/ChannelIntegrationRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/ChannelIntegrationRegistrationExtensions.cs @@ -174,7 +174,7 @@ internal static void AddDiscordChannel(IServiceCollection services, IConfigurati paths, logger); }) - .WithReminderResolver() + .WithReminderResolver((_, options) => new DiscordReminderTargetResolver(options)) .WithResolver((sp, options) => new DiscordAddressResolver( sp.GetRequiredService(), options, diff --git a/src/Netclaw.Daemon/Configuration/RemoteChatChannelBuilder.cs b/src/Netclaw.Daemon/Configuration/RemoteChatChannelBuilder.cs index 94af668d4..ef10a39ed 100644 --- a/src/Netclaw.Daemon/Configuration/RemoteChatChannelBuilder.cs +++ b/src/Netclaw.Daemon/Configuration/RemoteChatChannelBuilder.cs @@ -184,6 +184,17 @@ public RemoteChatChannelBuilder WithReminderResolverRegisters the channel's reminder target resolver. + public RemoteChatChannelBuilder WithReminderResolver( + Func factory) + where TResolver : class, IReminderTargetResolver + { + ArgumentNullException.ThrowIfNull(factory); + if (_options.Enabled) + _services.AddSingleton(sp => factory(sp, _options)); + return this; + } + /// /// Registers the channel's thread history fetcher, keyed by the channel /// key. Keying is load-bearing: an unkeyed registration would make every From dc2cc733c2ef558cb69df818b1af6fd46fefe8e4 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 11 Jul 2026 15:07:54 -0500 Subject: [PATCH 2/9] Refactor ModelContextProtocol versioning in props file (#1614) Updated ModelContextProtocol package versions to use a variable for versioning. Signed-off-by: Aaron Stannard --- Directory.Packages.props | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 681d538bd..915513305 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,7 @@ that trips NU1902 under our TreatWarningsAsErrors policy. --> 13.4.6 13.4.0 + 1.4.1 @@ -43,8 +44,8 @@ - - + + From 09c63c69816a0b8915501e702b06795b11462ea8 Mon Sep 17 00:00:00 2001 From: "petabridge-netclaw[bot]" <289234546+petabridge-netclaw[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:40:47 -0500 Subject: [PATCH 3/9] fix: serialize Slack processing status updates (#1556) Co-authored-by: Aaron Stannard --- .../SlackSessionBindingContractTests.cs | 96 +++++++++++++++++++ .../SlackThreadBindingActor.cs | 39 +++++++- 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/SlackSessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/SlackSessionBindingContractTests.cs index defc6a63e..7bbcc26ec 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/SlackSessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/SlackSessionBindingContractTests.cs @@ -227,6 +227,61 @@ await AwaitAssertAsync(() => }, cancellationToken: ct); } + [Fact] + public async Task Processing_state_renders_are_serialized_in_output_order() + { + var ct = TestContext.Current.CancellationToken; + var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe()); + var sid = new SessionId("session-slack-processing-ordered"); + var renderer = new OrderedProcessingRenderer(); + var registry = TestChannelRegistries.SlackWithProcessingRenderer(renderer); + var pipeline = new RecordingSessionPipeline(_ => + [ + new ProcessingStateOutput(true) { SessionId = sid }, + new ProcessingStateOutput(false) { SessionId = sid }, + new TurnCompleted { SessionId = sid, TurnNumber = new TurnNumber(1) } + ]); + + CreateActorCore(sid, pipeline, detector, channelRegistry: registry); + + await renderer.FirstStarted.WaitAsync(ct); + await AwaitAssertAsync( + () => Assert.NotEmpty(_replyClient.Posts), + cancellationToken: ct); + Assert.False(renderer.SecondStarted.IsCompleted); + + renderer.ReleaseFirst(); + await renderer.SecondStarted.WaitAsync(ct); + + Assert.Collection( + renderer.States, + state => Assert.True(state), + state => Assert.False(state)); + } + + [Fact] + public async Task Turn_completion_does_not_clear_status_while_session_remains_processing() + { + var ct = TestContext.Current.CancellationToken; + var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe()); + var sid = new SessionId("session-slack-processing-buffered-turn"); + var pipeline = new RecordingSessionPipeline(_ => + [ + new ProcessingStateOutput(true) { SessionId = sid }, + new TextOutput("First turn completed; continuing with buffered input.") { SessionId = sid }, + new TurnCompleted { SessionId = sid, TurnNumber = new TurnNumber(1) } + ]); + + CreateActorCore(sid, pipeline, detector); + + await AwaitAssertAsync(() => + { + Assert.Contains(_replyClient.Posts, p => p.Text == "First turn completed; continuing with buffered input."); + Assert.NotEmpty(_replyClient.Statuses); + Assert.All(_replyClient.Statuses, status => Assert.Equal("is thinking...", status.Status)); + }, cancellationToken: ct); + } + [Fact] public async Task Inbound_message_refreshes_active_processing_status() { @@ -580,4 +635,45 @@ public ValueTask RenderAsync( } } + private sealed class OrderedProcessingRenderer : IChannelOutputRenderer + { + private readonly object _lock = new(); + private readonly TaskCompletionSource _firstStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseFirst = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _secondStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly List _states = []; + + public ChannelDescriptorKey Key => ChannelDescriptorKey.FromChannelType(ChannelType.Slack); + public Task FirstStarted => _firstStarted.Task; + public Task SecondStarted => _secondStarted.Task; + public IReadOnlyList States + { + get { lock (_lock) return _states.ToList(); } + } + + public void ReleaseFirst() => _releaseFirst.TrySetResult(); + + public ValueTask RenderAsync( + ChannelOutputRenderRequest request, + CancellationToken cancellationToken = default) + { + var state = Assert.IsType(request.Output).IsProcessing; + int invocation; + lock (_lock) + { + _states.Add(state); + invocation = _states.Count; + } + + if (invocation == 1) + { + _firstStarted.TrySetResult(); + return new ValueTask(_releaseFirst.Task); + } + + _secondStarted.TrySetResult(); + return ValueTask.CompletedTask; + } + } + } diff --git a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs index 6a3435edb..b58891f38 100644 --- a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs +++ b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs @@ -62,6 +62,8 @@ internal sealed class SlackThreadBindingActor : ReceivePersistentActor, IWithTim private SlackEventTs? _cursorTs; private SlackEventTs? _pendingCursorTs; private volatile bool _processingIndicatorActive; + private readonly object _processingIndicatorRenderLock = new(); + private Task _processingIndicatorRenderTail = Task.CompletedTask; // Set when PerformOneShotHydrationAsync fetched a non-empty thread gap but // found no authorized trigger to anchor a turn. This is the proactive-thread @@ -1168,7 +1170,6 @@ private async Task HandleOutputAsync(ThreadOutput threadOutput) cleared => ApplyPendingApprovalPromptCleared(cleared)); } _pendingApprovalRequests.Clear(); - break; } } @@ -1185,11 +1186,39 @@ private Task RenderProcessingStateAsync(ProcessingStateOutput output) ChannelOutputEffectKind.ProcessingIndicator, requirement); - if (output.IsRequired) - return RenderProcessingStateRequestAsync(request, isRequired: true); + var renderTask = QueueProcessingStateRender(request, output.IsRequired); + return output.IsRequired ? renderTask : Task.CompletedTask; + } + + private Task QueueProcessingStateRender(ChannelOutputRenderRequest request, bool isRequired) + { + lock (_processingIndicatorRenderLock) + { + _processingIndicatorRenderTail = RenderAfterPreviousAsync( + _processingIndicatorRenderTail, + request, + isRequired); + return _processingIndicatorRenderTail; + } + } + + private async Task RenderAfterPreviousAsync( + Task previous, + ChannelOutputRenderRequest request, + bool isRequired) + { + try + { + await previous.ConfigureAwait(false); + } + catch (Exception ex) + { + // A failed required render is reported to its caller. It must not + // poison the queue and prevent newer state from reaching Slack. + _log.Warning(ex, "Previous required Slack processing indicator render failed; continuing with newer state"); + } - _ = RenderProcessingStateRequestAsync(request, isRequired: false); - return Task.CompletedTask; + await RenderProcessingStateRequestAsync(request, isRequired).ConfigureAwait(false); } private void QueueProcessingIndicatorClearIfActive() From e31ecdd663e3031cbd6e7628995ad0fc6d4842eb Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 11 Jul 2026 19:04:23 -0500 Subject: [PATCH 4/9] ci: run required checks for merge queue groups (#1617) --- .github/workflows/pr_validation.yml | 5 +++++ .github/workflows/smoke.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index f3e7ca2be..926626864 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -1,6 +1,11 @@ name: pr_validation on: + # Merge queues test a synthetic merge group rather than the pull request ref. + # Required checks must subscribe to this event or queued PRs will never merge. + merge_group: + types: + - checks_requested push: branches: - master diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index e7a61b259..5e9062704 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -11,6 +11,11 @@ name: smoke # surface via GitHub's built-in Actions notifications. on: + # Merge queues test a synthetic merge group rather than the pull request ref. + # Required checks must subscribe to this event or queued PRs will never merge. + merge_group: + types: + - checks_requested workflow_dispatch: schedule: # 07:00 UTC daily — catches external-dependency drift during quiet From d6c6c28bbe9339834c25db88b7c68b2c828437ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:07:31 -0500 Subject: [PATCH 5/9] Bump MessagePack from 3.1.7 to 3.1.8 (#1605) --- updated-dependencies: - dependency-name: MessagePack dependency-version: 3.1.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 915513305..67d953473 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -97,7 +97,7 @@ trips NU1903 (GHSA-hv8m-jj95-wg3x, LZ4 decompression AccessViolation). Keep this on the patched 3.x line already used by dev until Aspire ships a non-vulnerable transitive dependency on its own. --> - +