From 7f338c4a8cb51ab9e76edebd09fcd0f6929db2a0 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 25 May 2026 00:08:59 +0000 Subject: [PATCH 1/2] fix(channels): prevent cold approval path from swallowing normal chat The cold-text-approval path matches short replies like 'yes', 'a', '1' as potential approval responses regardless of context. When no pending approval exists and the session has never had an approval request, the message was consumed and a spurious 'approval prompt expired' notice was emitted. Check _resolvedToolApprovals and history for redrivable tool batches before emitting the expired notice. If no approval history exists, return approval_no_history so the channel falls through to normal LLM ingress. Fixes #1164 --- .../Contracts/SessionBindingContractTests.cs | 45 +++++++++++++++++++ .../TestHelpers/RecordingSessionPipeline.cs | 5 ++- .../Sessions/LlmSessionActor.cs | 22 +++++++-- .../DiscordSessionBindingActor.cs | 6 +++ .../MattermostSessionBindingActor.cs | 6 +++ .../SlackThreadBindingActor.cs | 6 +++ 6 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs index d2faf6754..79a0b8c18 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs @@ -480,6 +480,51 @@ await AwaitAssertAsync(() => }, cancellationToken: ct); } + // Regression for #1164: when no approval has ever been requested in the session, + // a short message like "yes", "a", or "1" should NOT be consumed by the cold + // approval path. The message must fall through to normal LLM ingress. + [Fact] + public async Task Normal_chat_text_that_looks_like_approval_is_not_consumed_when_no_approval_history() + { + var ct = TestContext.Current.CancellationToken; + var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe()); + var sid = new SessionId("session-cold-text-false-positive"); + + // Empty output stream: the binding never observed an approval prompt, + // so _hasObservedApprovalRequest stays false and the cold path is active. + // The ResponseFactory simulates the session rejecting the cold-path + // response with approval_no_history (meaning: no approval ever existed). + var pipeline = new RecordingSessionPipeline(_ => []) + { + ResponseFactory = (feedback, _) => + { + return feedback is ToolInteractionTextResponse + ? Task.FromResult(CommandNack.For(sid, "approval_no_history")) + : Task.FromResult(CommandAck.For(feedback.SessionId)); + } + }; + + var actor = CreateBindingActorWithPipeline(sid, pipeline, detector); + + // Send a message that LooksLikeApprovalResponse matches ("yes" -> ApproveOnce) + // but is ordinary conversation. With the fix, the message falls through + // to normal ChannelInput ingestion. + actor.Tell(CreateInboundMessage("yes", "user-1"), TestActor); + + await AwaitAssertAsync(() => + { + // The cold path should have forwarded the message to the session + Assert.Single(pipeline.RecordedFeedback.OfType()); + + // The message should NOT be consumed — it must fall through to normal input + Assert.NotEmpty(pipeline.CapturedInputs); + Assert.True( + pipeline.CapturedInputs.Any(ci => + ci.Contents.Any(c => c is TextContent tc && tc.Text == "yes")), + "The original message text should appear in ChannelInput"); + }, cancellationToken: ct); + } + // Regression for the silent-drop class of bugs: the binding observes a // ToolInteractionRequest then a TurnCompleted (which clears its local // _pendingApprovalRequests). A button click arriving afterwards must still diff --git a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingSessionPipeline.cs b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingSessionPipeline.cs index 15a7c770e..249353a1d 100644 --- a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingSessionPipeline.cs +++ b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingSessionPipeline.cs @@ -46,6 +46,7 @@ public RecordingSessionPipeline( public SessionPipelineOptions? CapturedOptions { get; private set; } public List RecordedFeedback { get; } = []; public ConcurrentQueue CapturedInputs { get; } = new(); + public Func>? ResponseFactory { get; set; } public Task CreateAsync( SessionId sessionId, @@ -125,6 +126,8 @@ public Task SendFeedbackAsync(IWithSessionId feedback, CancellationToken ct = de public Task SendFeedbackAndWaitAsync(IWithSessionId feedback, CancellationToken ct = default) { RecordedFeedback.Add(feedback); - return Task.FromResult(CommandAck.For(feedback.SessionId)); + var response = ResponseFactory?.Invoke(feedback, ct) + ?? Task.FromResult(CommandAck.For(feedback.SessionId)); + return response; } } diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 4d85874fa..1e068d2e1 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -3262,6 +3262,10 @@ private void EmitUsageOutput(UsageDetails usage) _ => ApprovalDecision.Denied }; + private bool HasApprovalHistory + => _resolvedToolApprovals.Count > 0 + || ParkedToolBatchHistory.FindRedrivableAssistantMessage(_state.History, null) is not null; + /// /// Emits the channel-visible "approval prompt expired" notice. Used when a /// tool interaction response cannot be honored — fail loud instead of @@ -3378,9 +3382,21 @@ private bool TryResolveTextApprovalResponse( { if (_pendingToolInteractions.Count == 0) { - _log.Warning("Ignoring text tool interaction response with no pending approvals for sender {SenderId}", msg.SenderId); - EmitExpiredPromptNotice(); - nackReason = "approval_prompt_expired"; + if (HasApprovalHistory) + { + _log.Warning("Ignoring text tool interaction response with no pending approvals for sender {SenderId}", msg.SenderId); + EmitExpiredPromptNotice(); + nackReason = "approval_prompt_expired"; + } + else + { + // Session has never had an approval request. The channel cold path + // matched the text as approval-like, but this is almost certainly + // ordinary conversation (e.g., "yes", "a", "1"). Don't emit a + // user-visible notice and don't consume — the channel should + // fall through to normal LLM ingress. See #1164. + nackReason = "approval_no_history"; + } } else { diff --git a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs index 478ffd5b9..f58433405 100644 --- a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs @@ -785,6 +785,12 @@ private async Task TryHandleColdTextApprovalResponseAsync(DiscordThreadInb return true; } + // approval_no_history means the session has never had an approval request. + // The message was a false-positive from LooksLikeApprovalResponse. + // Don't consume — let it fall through to normal LLM ingress. See #1164. + if (reply is CommandNack { Reason: "approval_no_history" }) + return false; + return reply is CommandNack; } catch (Exception ex) diff --git a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs index 43f3860ce..610f88061 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs @@ -760,6 +760,12 @@ private async Task TryHandleColdTextApprovalResponseAsync(MattermostThread return true; } + // approval_no_history means the session has never had an approval request. + // The message was a false-positive from LooksLikeApprovalResponse. + // Don't consume — let it fall through to normal LLM ingress. See #1164. + if (reply is CommandNack { Reason: "approval_no_history" }) + return false; + return reply is CommandNack; } catch (Exception ex) diff --git a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs index e419067ae..03eaae970 100644 --- a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs +++ b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs @@ -1304,6 +1304,12 @@ private async Task TryHandleColdTextApprovalResponseAsync(SlackThreadInbou return true; } + // approval_no_history means the session has never had an approval request. + // The message was a false-positive from LooksLikeApprovalResponse. + // Don't consume — let it fall through to normal LLM ingress. See #1164. + if (reply is CommandNack { Reason: "approval_no_history" }) + return false; + return reply is CommandNack; } catch (Exception ex) From 54e921cf7948cdf4b1f6d3afb75975117cb33312 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 25 May 2026 00:53:12 +0000 Subject: [PATCH 2/2] fix: centralize approval nack reasons and address review comments - Add ApprovalNackReasons static class with const string values to eliminate magic strings across session actor, channel bindings, and HTTP endpoints - Replace all inline approval_* strings with ApprovalNackReasons constants - Update MattermostActionEndpointExtensions to use constants in switch expression - Test now sends CommandNack for ToolInteractionTextResponse to simulate real session behavior when no approval history exists - Confirmed 'yes' is a keyword matched by LooksLikeApprovalResponse via TryParseNamedSelection Fixes #1164 --- .../Contracts/SessionBindingContractTests.cs | 4 +- .../Sessions/ApprovalRehydrationTests.cs | 2 +- .../Protocol/ApprovalNackReasons.cs | 44 +++++++++++++++++++ .../Sessions/LlmSessionActor.cs | 28 ++++++------ .../DiscordSessionBindingActor.cs | 2 +- .../MattermostSessionBindingActor.cs | 10 ++--- .../SlackThreadBindingActor.cs | 2 +- ...MattermostActionEndpointExtensionsTests.cs | 6 +-- .../MattermostActionEndpointExtensions.cs | 6 +-- 9 files changed, 74 insertions(+), 30 deletions(-) create mode 100644 src/Netclaw.Actors/Protocol/ApprovalNackReasons.cs diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs index 79a0b8c18..68ff71f82 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs @@ -499,7 +499,7 @@ public async Task Normal_chat_text_that_looks_like_approval_is_not_consumed_when ResponseFactory = (feedback, _) => { return feedback is ToolInteractionTextResponse - ? Task.FromResult(CommandNack.For(sid, "approval_no_history")) + ? Task.FromResult(CommandNack.For(sid, ApprovalNackReasons.NoHistory)) : Task.FromResult(CommandAck.For(feedback.SessionId)); } }; @@ -868,7 +868,7 @@ await AwaitAssertAsync(() => var nack = await probe.ExpectMsgAsync(cancellationToken: ct); Assert.Equal(sid, nack.SessionId); - Assert.Equal("approval_wrong_requester", nack.Reason); + Assert.Equal(ApprovalNackReasons.WrongRequester, nack.Reason); } [Fact] diff --git a/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs index 30ada0c5f..0bec04d05 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs @@ -195,7 +195,7 @@ await subscriber.ExpectMsgAsync( }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); var nack = Assert.IsType(invalidReply); - Assert.Equal("approval_option_unavailable", nack.Reason); + Assert.Equal(ApprovalNackReasons.OptionUnavailable, nack.Reason); var warning = await subscriber.ExpectMsgAsync( TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); diff --git a/src/Netclaw.Actors/Protocol/ApprovalNackReasons.cs b/src/Netclaw.Actors/Protocol/ApprovalNackReasons.cs new file mode 100644 index 000000000..0be43a3a7 --- /dev/null +++ b/src/Netclaw.Actors/Protocol/ApprovalNackReasons.cs @@ -0,0 +1,44 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +namespace Netclaw.Actors.Protocol; + +/// +/// Canonical nack reasons for tool approval responses. Centralizes the magic +/// strings used by when a +/// or +/// cannot be honored. Every consumer — session actor, channel bindings, +/// HTTP callback endpoints — should reference these constants. +/// +public static class ApprovalNackReasons +{ + /// + /// No pending approval exists and the session has no history of ever having + /// requested approval. The channel cold-path matched the text as approval-like, + /// but this is a false positive — the message should fall through to normal ingress. + /// + public const string NoHistory = "approval_no_history"; + + /// + /// No pending approval exists, but the session has a history of approval activity. + /// The prompt has expired. + /// + public const string PromptExpired = "approval_prompt_expired"; + + /// + /// The responding sender is not authorized to approve this tool action. + /// + public const string WrongRequester = "approval_wrong_requester"; + + /// + /// The selected option key was not among the options offered in the original prompt. + /// + public const string OptionUnavailable = "approval_option_unavailable"; + + /// + /// The approval decision could not be persisted. + /// + public const string PersistFailed = "approval_persist_failed"; +} diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 1e068d2e1..092828a02 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -576,7 +576,7 @@ private void Processing() if (!TryResolveTextApprovalResponse(msg, out var structured, out var nackReason) || structured is null) { - TryReplyNack(nackReason ?? "approval_prompt_expired"); + TryReplyNack(nackReason ?? ApprovalNackReasons.PromptExpired); return; } @@ -3321,7 +3321,7 @@ private string ClassifyUnknownApprovalCall(string callId) "Ignoring tool interaction response for call {CallId} from sender {SenderId}; expected {ExpectedSenderId}", msg.CallId, msg.SenderId, pending.RequesterSenderId); EmitWrongRequesterApprovalNotice(); - return (null, "approval_wrong_requester"); + return (null, ApprovalNackReasons.WrongRequester); } // Legacy journal entries created before option persistence landed have @@ -3336,7 +3336,7 @@ private string ClassifyUnknownApprovalCall(string callId) msg.CallId, string.Join(", ", pending.OptionKeys)); EmitUnavailableApprovalOptionNotice(); - return (null, "approval_option_unavailable"); + return (null, ApprovalNackReasons.OptionUnavailable); } var decision = MapApprovalDecision(msg.SelectedKey.Value); @@ -3386,7 +3386,7 @@ private bool TryResolveTextApprovalResponse( { _log.Warning("Ignoring text tool interaction response with no pending approvals for sender {SenderId}", msg.SenderId); EmitExpiredPromptNotice(); - nackReason = "approval_prompt_expired"; + nackReason = ApprovalNackReasons.PromptExpired; } else { @@ -3395,14 +3395,14 @@ private bool TryResolveTextApprovalResponse( // ordinary conversation (e.g., "yes", "a", "1"). Don't emit a // user-visible notice and don't consume — the channel should // fall through to normal LLM ingress. See #1164. - nackReason = "approval_no_history"; + nackReason = ApprovalNackReasons.NoHistory; } } else { _log.Warning("Ignoring text tool interaction response from unauthorized sender {SenderId}", msg.SenderId); EmitWrongRequesterApprovalNotice(); - nackReason = "approval_wrong_requester"; + nackReason = ApprovalNackReasons.WrongRequester; } return false; @@ -3431,7 +3431,7 @@ private bool TryResolveTextApprovalResponse( pending.CallId, string.Join(", ", pending.OptionKeys)); EmitUnavailableApprovalOptionNotice(); - nackReason = "approval_option_unavailable"; + nackReason = ApprovalNackReasons.OptionUnavailable; return false; } @@ -3459,7 +3459,7 @@ private async Task HandleProcessingApprovalResponseAsync(ToolInteractionResponse if (!_pendingToolInteractions.TryGetValue(msg.CallId.Value, out var pending)) { _log.Warning("Ignoring tool interaction response for unknown call {CallId}", msg.CallId); - TryReplyNack("approval_prompt_expired"); + TryReplyNack(ApprovalNackReasons.PromptExpired); return; } @@ -3468,7 +3468,7 @@ private async Task HandleProcessingApprovalResponseAsync(ToolInteractionResponse var authorization = await AuthorizeApprovalResponseAsync(pending, msg); if (authorization.Decision is not { } decision) { - TryReplyNack(authorization.NackReason ?? "approval_wrong_requester"); + TryReplyNack(authorization.NackReason ?? ApprovalNackReasons.WrongRequester); return; } @@ -3481,7 +3481,7 @@ private async Task HandleProcessingApprovalResponseAsync(ToolInteractionResponse catch (Exception ex) { FailCurrentTurn("I couldn't persist that approval decision. Please try again.", ex, ErrorCategory.ToolFailure); - TryReplyNack("approval_persist_failed"); + TryReplyNack(ApprovalNackReasons.PersistFailed); } } @@ -3536,7 +3536,7 @@ private async Task HandleToolInteractionResponseWhenIdle(ToolInteractionResponse "Tool interaction response for unknown/expired call {CallId} ({Classification}); pending={PendingCount} resolved={ResolvedCount}", msg.CallId, classification, _pendingToolInteractions.Count, _resolvedToolApprovals.Count); EmitExpiredPromptNotice(); - TryReplyNack("approval_prompt_expired"); + TryReplyNack(ApprovalNackReasons.PromptExpired); return; } @@ -3546,7 +3546,7 @@ private async Task HandleToolInteractionResponseWhenIdle(ToolInteractionResponse var authorization = await AuthorizeApprovalResponseAsync(pending, msg); if (authorization.Decision is not { } authorizedDecision) { - TryReplyNack(authorization.NackReason ?? "approval_wrong_requester"); + TryReplyNack(authorization.NackReason ?? ApprovalNackReasons.WrongRequester); return; } decision = authorizedDecision; @@ -3561,7 +3561,7 @@ private async Task HandleToolInteractionResponseWhenIdle(ToolInteractionResponse CorrelationId = Guid.NewGuid(), Cause = ex }); - TryReplyNack("approval_persist_failed"); + TryReplyNack(ApprovalNackReasons.PersistFailed); return; } @@ -3577,7 +3577,7 @@ private async Task HandleToolInteractionTextResponseWhenIdle(ToolInteractionText if (!TryResolveTextApprovalResponse(msg, out var structured, out var nackReason) || structured is null) { - TryReplyNack(nackReason ?? "approval_prompt_expired"); + TryReplyNack(nackReason ?? ApprovalNackReasons.PromptExpired); return; } diff --git a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs index f58433405..5ca5bfed2 100644 --- a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs @@ -788,7 +788,7 @@ private async Task TryHandleColdTextApprovalResponseAsync(DiscordThreadInb // approval_no_history means the session has never had an approval request. // The message was a false-positive from LooksLikeApprovalResponse. // Don't consume — let it fall through to normal LLM ingress. See #1164. - if (reply is CommandNack { Reason: "approval_no_history" }) + if (reply is CommandNack { Reason: ApprovalNackReasons.NoHistory }) return false; return reply is CommandNack; diff --git a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs index 610f88061..fcc1ce3b0 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs @@ -763,7 +763,7 @@ private async Task TryHandleColdTextApprovalResponseAsync(MattermostThread // approval_no_history means the session has never had an approval request. // The message was a false-positive from LooksLikeApprovalResponse. // Don't consume — let it fall through to normal LLM ingress. See #1164. - if (reply is CommandNack { Reason: "approval_no_history" }) + if (reply is CommandNack { Reason: ApprovalNackReasons.NoHistory }) return false; return reply is CommandNack; @@ -783,7 +783,7 @@ private async Task HandleApprovalResponseAsync(MattermostApprovalResponse messag if (result is ApprovalLookupResult.WrongRequester) { await SafeReplyAsync(WrongRequesterWarning); - ReplyIfExpected(replyTo, CommandNack.For(_sessionId, "approval_wrong_requester")); + ReplyIfExpected(replyTo, CommandNack.For(_sessionId, ApprovalNackReasons.WrongRequester)); return; } @@ -802,7 +802,7 @@ private async Task HandleApprovalResponseAsync(MattermostApprovalResponse messag catch (Exception ex) { _log.Error(ex, "Failed to route Mattermost approval response for call {CallId}", message.CallId); - ReplyIfExpected(replyTo, CommandNack.For(_sessionId, "approval_persist_failed")); + ReplyIfExpected(replyTo, CommandNack.For(_sessionId, ApprovalNackReasons.PersistFailed)); return; } @@ -810,7 +810,7 @@ private async Task HandleApprovalResponseAsync(MattermostApprovalResponse messag switch (feedbackResult) { case CommandNack nack: - if (string.Equals(nack.Reason, "approval_wrong_requester", StringComparison.Ordinal)) + if (string.Equals(nack.Reason, ApprovalNackReasons.WrongRequester, StringComparison.Ordinal)) await SafeReplyAsync(WrongRequesterWarning); ReplyIfExpected(replyTo, nack); return; @@ -828,7 +828,7 @@ private async Task HandleApprovalResponseAsync(MattermostApprovalResponse messag "Mattermost approval response for call {CallId} returned unexpected feedback result {ResultType}", message.CallId, feedbackResult.GetType().Name); - ReplyIfExpected(replyTo, CommandNack.For(_sessionId, "approval_persist_failed")); + ReplyIfExpected(replyTo, CommandNack.For(_sessionId, ApprovalNackReasons.PersistFailed)); return; } diff --git a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs index 03eaae970..ff4eff7a8 100644 --- a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs +++ b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs @@ -1307,7 +1307,7 @@ private async Task TryHandleColdTextApprovalResponseAsync(SlackThreadInbou // approval_no_history means the session has never had an approval request. // The message was a false-positive from LooksLikeApprovalResponse. // Don't consume — let it fall through to normal LLM ingress. See #1164. - if (reply is CommandNack { Reason: "approval_no_history" }) + if (reply is CommandNack { Reason: ApprovalNackReasons.NoHistory }) return false; return reply is CommandNack; diff --git a/src/Netclaw.Daemon.Tests/Configuration/MattermostActionEndpointExtensionsTests.cs b/src/Netclaw.Daemon.Tests/Configuration/MattermostActionEndpointExtensionsTests.cs index 04a6abab8..388a92b01 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/MattermostActionEndpointExtensionsTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/MattermostActionEndpointExtensionsTests.cs @@ -117,7 +117,7 @@ public async Task Wrong_requester_returns_explicit_rejection_message() await using var app = await CreateHostAsync( time, actionStore, - gatewayResponseFactory: _ => CommandNack.For(new SessionId("ch-1/root-1"), "approval_wrong_requester"), + gatewayResponseFactory: _ => CommandNack.For(new SessionId("ch-1/root-1"), ApprovalNackReasons.WrongRequester), recorder: new GatewayInteractionRecorder()); var client = app.GetTestClient(); @@ -144,7 +144,7 @@ public async Task Expired_prompt_returns_explicit_rejection_message() await using var app = await CreateHostAsync( time, actionStore, - gatewayResponseFactory: _ => CommandNack.For(new SessionId("ch-1/root-1"), "approval_prompt_expired"), + gatewayResponseFactory: _ => CommandNack.For(new SessionId("ch-1/root-1"), ApprovalNackReasons.PromptExpired), recorder: new GatewayInteractionRecorder()); var client = app.GetTestClient(); @@ -171,7 +171,7 @@ public async Task Unavailable_option_returns_explicit_rejection_message() await using var app = await CreateHostAsync( time, actionStore, - gatewayResponseFactory: _ => CommandNack.For(new SessionId("ch-1/root-1"), "approval_option_unavailable"), + gatewayResponseFactory: _ => CommandNack.For(new SessionId("ch-1/root-1"), ApprovalNackReasons.OptionUnavailable), recorder: new GatewayInteractionRecorder()); var client = app.GetTestClient(); diff --git a/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs index 665f36dc8..5a28b712d 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs @@ -238,9 +238,9 @@ internal static void AddMattermostActionEndpointRateLimiting(this IServiceCollec private static string MapRejectMessage(string reason) => reason switch { - "approval_wrong_requester" => "Only the requesting user can approve this tool action.", - "approval_prompt_expired" => "That approval prompt has expired. Please re-issue the request and try again.", - "approval_option_unavailable" => "That approval option is not available for this tool action. Please use one of the options shown on the prompt.", + ApprovalNackReasons.WrongRequester => "Only the requesting user can approve this tool action.", + ApprovalNackReasons.PromptExpired => "That approval prompt has expired. Please re-issue the request and try again.", + ApprovalNackReasons.OptionUnavailable => "That approval option is not available for this tool action. Please use one of the options shown on the prompt.", SessionIngressGate.RestartInProgressMessage => SessionIngressGate.RestartInProgressMessage, _ => "That approval could not be recorded. Please try again." };