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 @@ -163,4 +163,70 @@ public void TryParseButtonValue_handles_missing_requester()
Assert.Equal(ApprovalOptionKeys.Deny, selectedKey);
Assert.Null(requesterSenderId);
}

[Fact]
public void BuildResolvedPromptText_approve_once_shows_checkmark()
{
var request = new ToolInteractionRequest
{
SessionId = new SessionId("test/session"),
Kind = "approval",
CallId = "call-r1",
ToolName = "git_push",
DisplayText = "push to origin/main",
Patterns = ["origin/main"],
Options = [new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel)]
};

var text = DiscordApprovalPromptBuilder.BuildResolvedPromptText(
request, ApprovalOptionKeys.ApproveOnce, "user-42");

Assert.Contains(":white_check_mark:", text);
Assert.Contains("git_push", text);
Assert.Contains("push to origin/main", text);
Assert.Contains("origin/main", text);
Assert.Contains(ApprovalOptionKeys.ApproveOnceLabel, text);
Assert.Contains("<@user-42>", text);
}

[Fact]
public void BuildResolvedPromptText_deny_shows_no_entry()
{
var request = new ToolInteractionRequest
{
SessionId = new SessionId("test/session"),
Kind = "approval",
CallId = "call-r2",
ToolName = "rm_file",
DisplayText = "delete /etc/passwd",
Options = [new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel)]
};

var text = DiscordApprovalPromptBuilder.BuildResolvedPromptText(
request, ApprovalOptionKeys.Deny, "user-99");

Assert.Contains(":no_entry:", text);
Assert.Contains(ApprovalOptionKeys.DenyLabel, text);
Assert.DoesNotContain(":white_check_mark:", text);
}

[Fact]
public void BuildResolvedPromptText_omits_patterns_when_empty()
{
var request = new ToolInteractionRequest
{
SessionId = new SessionId("test/session"),
Kind = "approval",
CallId = "call-r3",
ToolName = "read_file",
DisplayText = "read config.json",
Patterns = [],
Options = [new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel)]
};

var text = DiscordApprovalPromptBuilder.BuildResolvedPromptText(
request, ApprovalOptionKeys.ApproveOnce, "user-1");

Assert.DoesNotContain("Pattern", text);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -404,17 +404,25 @@ public Task<DiscordPostResult> PostReplyAsync(DiscordPostMessage message, Cancel
{
Posts.Add(message);

DiscordPostResult result = DiscordPostResult.Default;
DiscordPostResult result;
if (message.CreateThreadOnMessage is not null)
{
var threadId = new DiscordReplyChannelId($"thread-{message.CreateThreadOnMessage.Value.Value}");
result = new DiscordPostResult(CreatedThreadId: threadId);
}
else
{
result = DiscordPostResult.Default;
}

return Task.FromResult(result);
}

public Task SetThreadNameAsync(DiscordReplyChannelId threadChannelId, string name, CancellationToken cancellationToken = default)
=> Task.CompletedTask;

public Task UpdateMessageAsync(DiscordReplyChannelId channelId, DiscordMessageId messageId, string text,
bool removeComponents = false, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,28 @@ internal sealed class RecordingDiscordReplyClient : IDiscordReplyClient
{
public List<DiscordPostMessage> Posts { get; } = [];
public List<(DiscordReplyChannelId ThreadId, string Name)> ThreadRenames { get; } = [];
public List<(DiscordReplyChannelId ChannelId, DiscordMessageId MessageId, string Text, bool RemoveComponents)> Updates { get; } = [];
public Exception? ThrowOnPost { get; set; }

private int _messageCounter;

public Task<DiscordPostResult> PostReplyAsync(DiscordPostMessage message, CancellationToken cancellationToken = default)
{
if (ThrowOnPost is { } ex)
throw ex;

Posts.Add(message);

DiscordPostResult result = DiscordPostResult.Default;
var messageId = new DiscordMessageId($"msg-{Interlocked.Increment(ref _messageCounter)}");
DiscordPostResult result;
if (message.CreateThreadOnMessage is not null)
{
var threadId = new DiscordReplyChannelId($"thread-{message.CreateThreadOnMessage.Value.Value}");
result = new DiscordPostResult(CreatedThreadId: threadId);
result = new DiscordPostResult(CreatedThreadId: threadId, MessageId: messageId);
}
else
{
result = new DiscordPostResult(MessageId: messageId);
}

return Task.FromResult(result);
Expand All @@ -30,4 +38,11 @@ public Task SetThreadNameAsync(DiscordReplyChannelId threadChannelId, string nam
ThreadRenames.Add((threadChannelId, name));
return Task.CompletedTask;
}

public Task UpdateMessageAsync(DiscordReplyChannelId channelId, DiscordMessageId messageId, string text,
bool removeComponents = false, CancellationToken cancellationToken = default)
{
Updates.Add((channelId, messageId, text, removeComponents));
return Task.CompletedTask;
}
}
62 changes: 44 additions & 18 deletions src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,48 @@ public static (string Text, IReadOnlyList<DiscordButtonSpec> Buttons) BuildButto
{
var sb = new StringBuilder();
sb.AppendLine(":lock: **Tool approval required**");
AppendToolSummary(sb, request);

sb.AppendLine();
sb.Append("You can also reply with `A`, `B`, `C`, or `D` in this thread.");

var buttons = request.Options
.Select(option => new DiscordButtonSpec(
CustomId: BuildButtonValue(request, option),
Label: option.Label,
Style: GetButtonStyle(option.Key)))
.ToList();

return (sb.ToString().TrimEnd(), buttons);
}

public static string BuildDecisionStatus(string selectedKey)
{
var label = GetDecisionLabel(selectedKey);
return $"Recorded approval decision: {label}.";
}

public static string BuildResolvedPromptText(
ToolInteractionRequest request,
string selectedKey,
string senderId)
{
var statusEmoji = selectedKey == ApprovalOptionKeys.Deny
? ":no_entry:"
: ":white_check_mark:";
var decisionLabel = GetDecisionLabel(selectedKey);

var sb = new StringBuilder();
sb.Append(statusEmoji).AppendLine(" **Tool approval resolved**");
AppendToolSummary(sb, request);

sb.Append("**Decision:** ").Append(decisionLabel);
sb.Append(" (by <@").Append(senderId).Append(">)");
return sb.ToString();
}

private static void AppendToolSummary(StringBuilder sb, ToolInteractionRequest request)
{
sb.Append("**Tool:** `").Append(request.ToolName).AppendLine("`");
sb.Append("**Action:** `").Append(request.DisplayText).AppendLine("`");

Expand All @@ -45,23 +87,10 @@ public static (string Text, IReadOnlyList<DiscordButtonSpec> Buttons) BuildButto
sb.Append(" • `").Append(pattern).AppendLine("`");
}
}

sb.AppendLine();
sb.Append("You can also reply with `A`, `B`, `C`, or `D` in this thread.");

var buttons = request.Options
.Select(option => new DiscordButtonSpec(
CustomId: BuildButtonValue(request, option),
Label: option.Label,
Style: GetButtonStyle(option.Key)))
.ToList();

return (sb.ToString().TrimEnd(), buttons);
}

public static string BuildDecisionStatus(string selectedKey)
{
var label = selectedKey switch
private static string GetDecisionLabel(string selectedKey)
=> selectedKey switch
{
ApprovalOptionKeys.ApproveOnce => ApprovalOptionKeys.ApproveOnceLabel,
ApprovalOptionKeys.ApproveSession => ApprovalOptionKeys.ApproveSessionLabel,
Expand All @@ -70,9 +99,6 @@ public static string BuildDecisionStatus(string selectedKey)
_ => selectedKey
};

return $"Recorded approval decision: {label}.";
}

internal static string BuildButtonValue(ToolInteractionRequest request, ToolInteractionOption option)
=> ApprovalButtonValueCodec.Encode(request, option);

Expand Down
15 changes: 11 additions & 4 deletions src/Netclaw.Channels.Discord/DiscordIngressMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,15 @@ public sealed record DiscordApprovalResponse(
DiscordUserId SenderId,
DiscordUserId? RequesterSenderId = null);

internal sealed record PendingApprovalRequest(
string CallId,
DiscordUserId? RequesterSenderId,
PrincipalClassification? RequesterPrincipal);
internal sealed class PendingApprovalRequest(ToolInteractionRequest request)
{
public ToolInteractionRequest Request { get; } = request;
public string CallId => Request.CallId;

public DiscordUserId? RequesterSenderId { get; } =
request.RequesterSenderId is not null ? new DiscordUserId(request.RequesterSenderId) : null;

public PrincipalClassification? RequesterPrincipal => Request.RequesterPrincipal;
public DiscordMessageId? PromptMessageId { get; set; }
}

65 changes: 52 additions & 13 deletions src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse
SenderId = message.SenderId.Value
});

await SafeReplyAsync(DiscordApprovalPromptBuilder.BuildDecisionStatus(selectedKey));
await TryResolveApprovalPromptAsync(pending!, selectedKey, message.SenderId.Value);
return true;
}

Expand Down Expand Up @@ -522,7 +522,40 @@ await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse
SenderId = message.SenderId.Value
});

await SafeReplyAsync(DiscordApprovalPromptBuilder.BuildDecisionStatus(message.SelectedKey));
await TryResolveApprovalPromptAsync(pending!, message.SelectedKey, message.SenderId.Value);
}

private async Task TryResolveApprovalPromptAsync(
PendingApprovalRequest pending,
string selectedKey,
string senderId)
{
if (pending.PromptMessageId is not { } promptMessageId)
return;

try
{
var resolvedText = DiscordApprovalPromptBuilder.BuildResolvedPromptText(
pending.Request,
selectedKey,
senderId);

using var cts = new CancellationTokenSource(OperationTimeout);
await _dependencies.ReplyClient.UpdateMessageAsync(
_replyChannelId,
promptMessageId,
resolvedText,
removeComponents: true,
cts.Token);
}
catch (Exception ex)
{
_log.Warning(
ex,
"Failed to update resolved approval prompt for call {CallId} messageId={MessageId}",
pending.CallId,
promptMessageId.Value);
}
}

private async Task HandleTrustedReminderAsync(DeliverTrustedSessionTurn message)
Expand Down Expand Up @@ -634,13 +667,18 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg)
break;

case ToolInteractionRequest request when string.Equals(request.Kind, "approval", StringComparison.OrdinalIgnoreCase):
_pendingApprovalRequests.Add(new PendingApprovalRequest(
request.CallId,
request.RequesterSenderId is null ? null : new DiscordUserId(request.RequesterSenderId),
request.RequesterPrincipal));
var pendingApproval = new PendingApprovalRequest(request);
_pendingApprovalRequests.Add(pendingApproval);

var (promptText, buttons) = DiscordApprovalPromptBuilder.BuildButtonPrompt(request);
await SafeReplyWithButtonsAsync(promptText, buttons, request);
var promptMessageId = await SafeReplyWithButtonsAsync(request);
if (promptMessageId is not null)
{
pendingApproval.PromptMessageId = promptMessageId;
}
else
{
_pendingApprovalRequests.Remove(pendingApproval);
}
break;

case SessionTitleOutput titleOutput:
Expand Down Expand Up @@ -671,20 +709,19 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg)
}
}

private async Task SafeReplyWithButtonsAsync(
string text,
IReadOnlyList<DiscordButtonSpec> buttons,
ToolInteractionRequest request)
private async Task<DiscordMessageId?> SafeReplyWithButtonsAsync(ToolInteractionRequest request)
{
var (promptText, buttons) = DiscordApprovalPromptBuilder.BuildButtonPrompt(request);
var startedAt = _dependencies.TimeProvider.GetTimestamp();
try
{
var postMessage = BuildPostMessage(text, buttons: buttons);
var postMessage = BuildPostMessage(promptText, buttons: buttons);
var result = await _dependencies.ReplyClient.PostReplyAsync(postMessage);
ApplyThreadPromotion(result);
var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds;
ChannelTelemetry.RecordDiscordReplyPosted(duration);
ChannelTelemetry.RecordDiscordApprovalFallbackActivated("button_prompt");
return result.MessageId;
}
catch (Exception ex)
{
Expand All @@ -696,11 +733,13 @@ private async Task SafeReplyWithButtonsAsync(
var postMessage = BuildPostMessage(fallbackText);
var result = await _dependencies.ReplyClient.PostReplyAsync(postMessage);
ApplyThreadPromotion(result);
return result.MessageId;
}
catch (Exception textEx)
{
_log.Error(textEx, "Failed posting text-only approval fallback; auto-denying request");
await SendApprovalDenyOnFailureAsync(request.CallId);
return null;
}
}
}
Expand Down
15 changes: 14 additions & 1 deletion src/Netclaw.Channels.Discord/DiscordTransportContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ public interface IDiscordReplyClient
Task<DiscordPostResult> PostReplyAsync(DiscordPostMessage message, CancellationToken cancellationToken = default);

Task SetThreadNameAsync(DiscordReplyChannelId threadChannelId, string name, CancellationToken cancellationToken = default);

Task UpdateMessageAsync(
DiscordReplyChannelId channelId,
DiscordMessageId messageId,
string text,
bool removeComponents = false,
CancellationToken cancellationToken = default);
}

public sealed record DiscordPostMessage(
Expand All @@ -61,7 +68,8 @@ public sealed record DiscordPostMessage(
string? ThreadName = null);

public sealed record DiscordPostResult(
DiscordReplyChannelId? CreatedThreadId = null)
DiscordReplyChannelId? CreatedThreadId = null,
DiscordMessageId? MessageId = null)
{
public static readonly DiscordPostResult Default = new();
}
Expand Down Expand Up @@ -121,4 +129,9 @@ public Task<DiscordPostResult> PostReplyAsync(DiscordPostMessage message, Cancel
public Task SetThreadNameAsync(DiscordReplyChannelId threadChannelId, string name, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException(
"Discord channel attempted to set thread name, but no Discord reply client is configured.");

public Task UpdateMessageAsync(DiscordReplyChannelId channelId, DiscordMessageId messageId, string text,
bool removeComponents = false, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException(
"Discord channel attempted to update a message, but no Discord reply client is configured.");
}
Loading
Loading