diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/SlackSessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/SlackSessionBindingContractTests.cs index 623c15e17..defc6a63e 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/SlackSessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/SlackSessionBindingContractTests.cs @@ -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() { diff --git a/src/Netclaw.Channels.Discord/DiscordProcessingOutputRenderer.cs b/src/Netclaw.Channels.Discord/DiscordProcessingOutputRenderer.cs index fb24cff91..02fac0d35 100644 --- a/src/Netclaw.Channels.Discord/DiscordProcessingOutputRenderer.cs +++ b/src/Netclaw.Channels.Discord/DiscordProcessingOutputRenderer.cs @@ -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); diff --git a/src/Netclaw.Channels.Slack/SlackProcessingOutputRenderer.cs b/src/Netclaw.Channels.Slack/SlackProcessingOutputRenderer.cs index 93a6f7b58..11a70e173 100644 --- a/src/Netclaw.Channels.Slack/SlackProcessingOutputRenderer.cs +++ b/src/Netclaw.Channels.Slack/SlackProcessingOutputRenderer.cs @@ -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), diff --git a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs index 32f71bf9c..bd18a387c 100644 --- a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs +++ b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs @@ -423,6 +423,8 @@ await ProcessInboundAttachmentsAsync( if (_pendingCursorTs is not { } pending || ts.CompareTo(pending) > 0) _pendingCursorTs = ts; } + + QueueProcessingIndicatorRefreshIfActive(); } catch (OperationCanceledException ex) { @@ -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) @@ -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)