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 @@ -227,6 +227,60 @@ await AwaitAssertAsync(() =>
}, cancellationToken: ct);
}

[Fact]
public async Task Inbound_message_refreshes_active_processing_status()
{
var ct = TestContext.Current.CancellationToken;
var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe());
var sid = new SessionId("session-slack-processing-inbound-refresh");
var pipeline = new RecordingSessionPipeline(_ =>
[
new ProcessingStateOutput(true) { SessionId = sid }
]);
var actor = CreateActorCore(sid, pipeline, detector);

await AwaitAssertAsync(() =>
{
var status = Assert.Single(_replyClient.Statuses);
Assert.Equal("is thinking...", status.Status);
}, cancellationToken: ct);

actor.Tell(CreateInboundMessage("new context while you are working", "user-1"));

await AwaitAssertAsync(() =>
{
Assert.NotEmpty(pipeline.CapturedInputs);
Assert.Collection(
_replyClient.Statuses,
status => Assert.Equal("is thinking...", status.Status),
status => Assert.Equal("is thinking...", status.Status));
}, cancellationToken: ct);
}

[Fact]
public async Task Slack_reply_refreshes_active_processing_status()
{
var ct = TestContext.Current.CancellationToken;
var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe());
var sid = new SessionId("session-slack-processing-post-refresh");
var pipeline = new RecordingSessionPipeline(_ =>
[
new ProcessingStateOutput(true) { SessionId = sid },
new TextOutput("I found the first result and am still working.") { SessionId = sid }
]);

CreateActorCore(sid, pipeline, detector);

await AwaitAssertAsync(() =>
{
Assert.Contains(_replyClient.Posts, p => p.Text == "I found the first result and am still working.");
Assert.Collection(
_replyClient.Statuses,
status => Assert.Equal("is thinking...", status.Status),
status => Assert.Equal("is thinking...", status.Status));
}, cancellationToken: ct);
}

[Fact]
public async Task Processing_state_output_does_not_block_text_when_renderer_stalls()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public async ValueTask RenderAsync(
if (request.Output is not ProcessingStateOutput { IsProcessing: true })
return;

// Discord typing is a transient pulse; Discord.Net documents this
// call as broadcasting typing for 10 seconds.
// https://discord.com/developers/docs/resources/channel#trigger-typing-indicator
// https://docs.discordnet.dev/api/Discord.IMessageChannel.html#Discord_IMessageChannel_TriggerTypingAsync_Discord_RequestOptions_
await replyClient.TriggerTypingAsync(
new DiscordReplyChannelId(request.Target.Destination.StableId),
cancellationToken);
Expand Down
3 changes: 3 additions & 0 deletions src/Netclaw.Channels.Slack/SlackProcessingOutputRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public async ValueTask RenderAsync(
if (string.IsNullOrWhiteSpace(request.Target.ThreadOrRootId))
throw new InvalidOperationException("Slack processing indicators require a thread timestamp.");

// Slack assistant thread status is stateful: Slack clears it when the
// app sends a reply, after a timeout, or when an empty status is sent.
// https://docs.slack.dev/reference/methods/assistant.threads.setStatus/
await replyClient.SetThreadStatusAsync(
new SlackChannelId(request.Target.Destination.StableId),
new SlackThreadTs(request.Target.ThreadOrRootId),
Expand Down
17 changes: 17 additions & 0 deletions src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,8 @@ await ProcessInboundAttachmentsAsync(
if (_pendingCursorTs is not { } pending || ts.CompareTo(pending) > 0)
_pendingCursorTs = ts;
}

QueueProcessingIndicatorRefreshIfActive();
}
catch (OperationCanceledException ex)
{
Expand Down Expand Up @@ -1201,6 +1203,20 @@ private void QueueProcessingIndicatorClearIfActive()
});
}

private void QueueProcessingIndicatorRefreshIfActive()
{
if (!_processingIndicatorActive)
return;

// Slack clears assistant thread status when the app sends a reply; keep
// long-running turns visible after Slack-side thread activity while the
// session still reports Processing. See SlackProcessingOutputRenderer.
_ = RenderProcessingStateAsync(new ProcessingStateOutput(true)
{
SessionId = _sessionId
});
}

private async Task RenderProcessingStateRequestAsync(
ChannelOutputRenderRequest request,
bool isRequired)
Expand Down Expand Up @@ -1530,6 +1546,7 @@ await _dependencies.ReplyClient.PostThreadReplyAsync(new SlackPostMessage(

_log.Info("Posted Slack reply message");
ChannelTelemetry.For(ChannelType.Slack).RecordReplyPosted(_dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds);
QueueProcessingIndicatorRefreshIfActive();
return PostResult.Ok;
}
catch (OperationCanceledException ex)
Expand Down
Loading