From f02b46dd7366281930097c0be133a2cab52ac240 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 30 Jun 2026 21:11:14 +0000 Subject: [PATCH 1/4] fix(slack): refresh processing status during tool loops --- .../Sessions/ToolExecutionIntegrationTests.cs | 59 +++++++++++++++++++ .../Sessions/LlmSessionActor.cs | 14 ++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs index 53cbb072e..cc71575bb 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs @@ -119,6 +119,65 @@ await sessionManager.Ask(new SendUserMessage Assert.True(_fakeAuditLogger.Entries[0].Allowed); } + [Fact] + public async Task Tool_loop_reemits_processing_state_for_followup_llm_call() + { + _fakeChatClient.ToolCallsOnFirstCall = + [ + new FunctionCallContent("call-1", "web_search", + new Dictionary { ["query"] = "test query" }) + ]; + _fakeToolExecutor.Results["web_search"] = "Found 3 results for test query"; + + var sessionId = new SessionId("test-channel/tool-processing-refresh"); + var sessionManager = ActorRegistry.Get(); + var subscriber = CreateTestProbe("tool-processing-refresh-sub"); + + await sessionManager.Ask(new JoinSession(subscriber) + { + SessionId = sessionId, + Filter = OutputFilter.Full | OutputFilter.ProcessingState + }, TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); + + await sessionManager.Ask(new SendUserMessage + { + SessionId = sessionId, + Content = "Search for test query" + }, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + + var initialProcessing = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(initialProcessing.IsProcessing); + + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + + var followupProcessing = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(followupProcessing.IsProcessing); + + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(5), + cancellationToken: TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + + var idle = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + Assert.False(idle.IsProcessing); + + Assert.Equal(2, _fakeChatClient.CallCount); + } + [Fact] public async Task Multiple_tool_calls_in_single_response_all_executed() { diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 0c9a76a57..2589ef2e0 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -372,8 +372,16 @@ private void TransitionTo(SessionPhase target) private void EmitProcessingStateForPhase(SessionPhase phase) { var isProcessing = phase is SessionPhase.Processing or SessionPhase.Compacting; + EmitProcessingState(isProcessing, force: false); + } + + private void EmitProcessingState(bool isProcessing, bool force) + { if (_processingStateActive == isProcessing) - return; + { + if (!force) + return; + } _processingStateActive = isProcessing; EmitOutput(new ProcessingStateOutput(isProcessing) @@ -2628,6 +2636,10 @@ private static string ShortContentHash(string content) private void FireLlmCall(string? recallQuery = null, bool forceNoTools = false) { + // Channel-native busy indicators can expire or clear while a tool loop + // stays in Processing, so refresh on every LLM segment, not only phase changes. + EmitProcessingState(isProcessing: true, force: true); + _anyContentStreamed = false; CancelAndDisposeLlmCts(); _activeLlmCts = new CancellationTokenSource(); From 5d1b055c6936a7e2165beb544d2d501b401845dd Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 30 Jun 2026 21:31:20 +0000 Subject: [PATCH 2/4] fix(slack): refresh processing status for buffered messages --- .../Sessions/LlmSessionIntegrationTests.cs | 66 +++++++++++++++++++ .../Sessions/LlmSessionActor.cs | 2 + 2 files changed, 68 insertions(+) diff --git a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs index 9da7e4c09..c1a36facf 100644 --- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs @@ -769,6 +769,72 @@ await sessionManager.Ask(new SendUserMessage await subscriber.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(300), cancellationToken: TestContext.Current.CancellationToken); } + [Fact] + public async Task Buffered_user_message_reemits_processing_state_while_turn_is_active() + { + var firstResponseGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _fakeChatClient.NextResponseGate = firstResponseGate; + + var sessionId = new SessionId("channel-C/thread-processing-refresh"); + var sessionManager = ActorRegistry.Get(); + var subscriber = CreateTestProbe("buffered-processing-refresh"); + + await sessionManager.Ask(new JoinSession(subscriber) + { + SessionId = sessionId, + Filter = OutputFilter.TextOnly | OutputFilter.ProcessingState + }, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); + + await sessionManager.Ask(new SendUserMessage + { + SessionId = sessionId, + Content = "First message" + }, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + + var initialProcessing = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(initialProcessing.IsProcessing); + + await AwaitAssertAsync(() => + { + Assert.Equal(1, _fakeChatClient.CallCount); + return Task.CompletedTask; + }, TimeSpan.FromSeconds(3), TimeSpan.FromMilliseconds(100), cancellationToken: TestContext.Current.CancellationToken); + + await sessionManager.Ask(new SendUserMessage + { + SessionId = sessionId, + Content = "Second message" + }, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + + var bufferedRefresh = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(bufferedRefresh.IsProcessing); + + firstResponseGate.TrySetResult(); + + await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(6), cancellationToken: TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(6), cancellationToken: TestContext.Current.CancellationToken); + + var followupProcessing = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(followupProcessing.IsProcessing); + + await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(6), cancellationToken: TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(6), cancellationToken: TestContext.Current.CancellationToken); + + var idle = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + Assert.False(idle.IsProcessing); + + Assert.Equal(2, _fakeChatClient.CallCount); + } + [Fact] public async Task Discovered_tools_are_retained_then_expire_after_lease_window() { diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 2589ef2e0..65f57cc0f 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -494,6 +494,7 @@ private void Processing() } _deliveryRetry.Clear(); + EmitProcessingState(isProcessing: true, force: true); _log.Info("Buffering user message (LLM call in progress)"); _buffer.Add(cmd); TryReplyAck(); @@ -1124,6 +1125,7 @@ private void Compacting() return; } + EmitProcessingState(isProcessing: true, force: true); _log.Info("Buffering user message (compaction in progress)"); _buffer.Add(cmd); TryReplyAck(); From edd370b2e8a9e1485a7b5e01f10adff868485887 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 30 Jun 2026 21:41:53 +0000 Subject: [PATCH 3/4] fix(slack): refresh active thread status on Slack activity --- .../SlackSessionBindingContractTests.cs | 54 +++++++++++++++ .../Sessions/LlmSessionIntegrationTests.cs | 66 ------------------- .../Sessions/ToolExecutionIntegrationTests.cs | 59 ----------------- .../Sessions/LlmSessionActor.cs | 16 +---- .../SlackThreadBindingActor.cs | 14 ++++ 5 files changed, 69 insertions(+), 140 deletions(-) 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.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs index c1a36facf..9da7e4c09 100644 --- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs @@ -769,72 +769,6 @@ await sessionManager.Ask(new SendUserMessage await subscriber.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(300), cancellationToken: TestContext.Current.CancellationToken); } - [Fact] - public async Task Buffered_user_message_reemits_processing_state_while_turn_is_active() - { - var firstResponseGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _fakeChatClient.NextResponseGate = firstResponseGate; - - var sessionId = new SessionId("channel-C/thread-processing-refresh"); - var sessionManager = ActorRegistry.Get(); - var subscriber = CreateTestProbe("buffered-processing-refresh"); - - await sessionManager.Ask(new JoinSession(subscriber) - { - SessionId = sessionId, - Filter = OutputFilter.TextOnly | OutputFilter.ProcessingState - }, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); - await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); - - await sessionManager.Ask(new SendUserMessage - { - SessionId = sessionId, - Content = "First message" - }, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); - - var initialProcessing = await subscriber.ExpectMsgAsync( - TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.True(initialProcessing.IsProcessing); - - await AwaitAssertAsync(() => - { - Assert.Equal(1, _fakeChatClient.CallCount); - return Task.CompletedTask; - }, TimeSpan.FromSeconds(3), TimeSpan.FromMilliseconds(100), cancellationToken: TestContext.Current.CancellationToken); - - await sessionManager.Ask(new SendUserMessage - { - SessionId = sessionId, - Content = "Second message" - }, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); - - var bufferedRefresh = await subscriber.ExpectMsgAsync( - TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.True(bufferedRefresh.IsProcessing); - - firstResponseGate.TrySetResult(); - - await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(6), cancellationToken: TestContext.Current.CancellationToken); - await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(6), cancellationToken: TestContext.Current.CancellationToken); - - var followupProcessing = await subscriber.ExpectMsgAsync( - TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.True(followupProcessing.IsProcessing); - - await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(6), cancellationToken: TestContext.Current.CancellationToken); - await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(6), cancellationToken: TestContext.Current.CancellationToken); - - var idle = await subscriber.ExpectMsgAsync( - TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.False(idle.IsProcessing); - - Assert.Equal(2, _fakeChatClient.CallCount); - } - [Fact] public async Task Discovered_tools_are_retained_then_expire_after_lease_window() { diff --git a/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs index cc71575bb..53cbb072e 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs @@ -119,65 +119,6 @@ await sessionManager.Ask(new SendUserMessage Assert.True(_fakeAuditLogger.Entries[0].Allowed); } - [Fact] - public async Task Tool_loop_reemits_processing_state_for_followup_llm_call() - { - _fakeChatClient.ToolCallsOnFirstCall = - [ - new FunctionCallContent("call-1", "web_search", - new Dictionary { ["query"] = "test query" }) - ]; - _fakeToolExecutor.Results["web_search"] = "Found 3 results for test query"; - - var sessionId = new SessionId("test-channel/tool-processing-refresh"); - var sessionManager = ActorRegistry.Get(); - var subscriber = CreateTestProbe("tool-processing-refresh-sub"); - - await sessionManager.Ask(new JoinSession(subscriber) - { - SessionId = sessionId, - Filter = OutputFilter.Full | OutputFilter.ProcessingState - }, TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); - await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); - - await sessionManager.Ask(new SendUserMessage - { - SessionId = sessionId, - Content = "Search for test query" - }, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - - var initialProcessing = await subscriber.ExpectMsgAsync( - TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.True(initialProcessing.IsProcessing); - - await subscriber.ExpectMsgAsync( - TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - await subscriber.ExpectMsgAsync( - TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - - var followupProcessing = await subscriber.ExpectMsgAsync( - TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.True(followupProcessing.IsProcessing); - - await subscriber.ExpectMsgAsync( - TimeSpan.FromSeconds(5), - cancellationToken: TestContext.Current.CancellationToken); - await subscriber.ExpectMsgAsync( - TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - - var idle = await subscriber.ExpectMsgAsync( - TimeSpan.FromSeconds(3), - cancellationToken: TestContext.Current.CancellationToken); - Assert.False(idle.IsProcessing); - - Assert.Equal(2, _fakeChatClient.CallCount); - } - [Fact] public async Task Multiple_tool_calls_in_single_response_all_executed() { diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 65f57cc0f..0c9a76a57 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -372,16 +372,8 @@ private void TransitionTo(SessionPhase target) private void EmitProcessingStateForPhase(SessionPhase phase) { var isProcessing = phase is SessionPhase.Processing or SessionPhase.Compacting; - EmitProcessingState(isProcessing, force: false); - } - - private void EmitProcessingState(bool isProcessing, bool force) - { if (_processingStateActive == isProcessing) - { - if (!force) - return; - } + return; _processingStateActive = isProcessing; EmitOutput(new ProcessingStateOutput(isProcessing) @@ -494,7 +486,6 @@ private void Processing() } _deliveryRetry.Clear(); - EmitProcessingState(isProcessing: true, force: true); _log.Info("Buffering user message (LLM call in progress)"); _buffer.Add(cmd); TryReplyAck(); @@ -1125,7 +1116,6 @@ private void Compacting() return; } - EmitProcessingState(isProcessing: true, force: true); _log.Info("Buffering user message (compaction in progress)"); _buffer.Add(cmd); TryReplyAck(); @@ -2638,10 +2628,6 @@ private static string ShortContentHash(string content) private void FireLlmCall(string? recallQuery = null, bool forceNoTools = false) { - // Channel-native busy indicators can expire or clear while a tool loop - // stays in Processing, so refresh on every LLM segment, not only phase changes. - EmitProcessingState(isProcessing: true, force: true); - _anyContentStreamed = false; CancelAndDisposeLlmCts(); _activeLlmCts = new CancellationTokenSource(); diff --git a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs index 32f71bf9c..4ff5839b3 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,17 @@ private void QueueProcessingIndicatorClearIfActive() }); } + private void QueueProcessingIndicatorRefreshIfActive() + { + if (!_processingIndicatorActive) + return; + + _ = RenderProcessingStateAsync(new ProcessingStateOutput(true) + { + SessionId = _sessionId + }); + } + private async Task RenderProcessingStateRequestAsync( ChannelOutputRenderRequest request, bool isRequired) @@ -1530,6 +1543,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) From ad698b2b45fce12494b5b78cd8aaa28b20303037 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 30 Jun 2026 21:58:16 +0000 Subject: [PATCH 4/4] docs(channels): cite processing indicator API behavior --- .../DiscordProcessingOutputRenderer.cs | 4 ++++ src/Netclaw.Channels.Slack/SlackProcessingOutputRenderer.cs | 3 +++ src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs | 3 +++ 3 files changed, 10 insertions(+) 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 4ff5839b3..bd18a387c 100644 --- a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs +++ b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs @@ -1208,6 +1208,9 @@ 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