From 4e99816a87c0fb4047ccc4490b56a538f4ef17fc Mon Sep 17 00:00:00 2001 From: fchengyan <3181088318@qq.com> Date: Tue, 29 Sep 2026 13:07:12 +0800 Subject: [PATCH] fix(channels): deliver NO_REPLY placeholder cleanup signal to channels (#1586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent loop signals silent replies (NO_REPLY, cancelled runs, suppressed errors) by publishing an outbound message with empty content plus the inbound routing metadata (placeholder_key / local_key). Slack, Telegram and Discord each implement an empty-content branch in Send() that deletes their streamed 'Thinking...' placeholder — the partial draft left visible in the thread when delivery is suppressed. deliverOutbound skipped every text-only empty outbound, so that cleanup signal never reached channel.Send and the stray partial draft stayed published (issue #1475). Pass the signal through when placeholder routing metadata is present; keep skipping bare empty messages so channels without an empty-content branch never render empty bubbles. Fixes #1475 --- internal/channels/dispatch.go | 11 ++++++- internal/channels/dispatch_test.go | 52 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/internal/channels/dispatch.go b/internal/channels/dispatch.go index a4df9743..b51c76f0 100644 --- a/internal/channels/dispatch.go +++ b/internal/channels/dispatch.go @@ -148,7 +148,16 @@ func (m *Manager) deliverOutbound(ctx context.Context, msg bus.OutboundMessage) msg.Media = kept // If only media was in this message and every file is gone, skip entirely. - if len(msg.Media) == 0 && msg.Content == "" { + // Exception: empty content carrying placeholder routing metadata is the + // agent loop's NO_REPLY / silent-reply cleanup signal — channels such as + // Slack, Telegram and Discord implement an empty-content branch in Send() + // that deletes their streamed "Thinking..." placeholder. Dropping the + // message here leaves the partial streamed draft visible in the thread + // (issue #1475). Messages without that metadata are not cleanup signals + // and stay skipped so channels without an empty-content branch never + // render empty bubbles. + if len(msg.Media) == 0 && msg.Content == "" && + msg.Metadata["placeholder_key"] == "" && msg.Metadata["local_key"] == "" { return } diff --git a/internal/channels/dispatch_test.go b/internal/channels/dispatch_test.go index 4ba41ecf..37c16bfc 100644 --- a/internal/channels/dispatch_test.go +++ b/internal/channels/dispatch_test.go @@ -83,3 +83,55 @@ func TestHandleSendFailure_NonForwardTextOnlyDropped(t *testing.T) { t.Fatalf("expected no notice sent for non-forward text-only failure, got: %+v", ch.lastMsg) } } + +// Issue #1475: the agent loop signals NO_REPLY / silent replies by publishing +// an outbound message with empty content plus the inbound routing metadata +// (placeholder_key / local_key). Slack, Telegram and Discord implement an +// empty-content branch in Send() that deletes the streamed "Thinking..." +// placeholder — the stray partial draft left behind when delivery is +// suppressed. deliverOutbound must not drop these cleanup signals before +// they reach the channel. +func TestDeliverOutbound_EmptyContentWithPlaceholderMetaReachesSend(t *testing.T) { + t.Parallel() + + mgr := NewManager(bus.New()) + ch := newMockChannel("slack-main", TypeSlack) + mgr.channels["slack-main"] = ch + + msg := bus.OutboundMessage{ + Channel: "slack-main", + ChatID: "C012345", + Content: "", + Metadata: map[string]string{ + "placeholder_key": "C012345:thread:1727500000.000100", + "local_key": "C012345:thread:1727500000.000100", + }, + } + + mgr.deliverOutbound(context.Background(), msg) + + if ch.lastMsg.Content != "" || ch.lastMsg.ChatID != "C012345" { + t.Fatal("empty-content cleanup signal with placeholder metadata was dropped before reaching channel.Send — stray partial draft is never deleted (issue #1475)") + } +} + +// The media-gone skip must keep working for empty messages that carry no +// placeholder routing metadata: those are NOT cleanup signals, and delivering +// them would make channels without an empty-content branch render empty bubbles. +func TestDeliverOutbound_EmptyContentWithoutMetaStillSkipped(t *testing.T) { + t.Parallel() + + mgr := NewManager(bus.New()) + ch := newMockChannel("feishu-main", TypeFeishu) + mgr.channels["feishu-main"] = ch + + mgr.deliverOutbound(context.Background(), bus.OutboundMessage{ + Channel: "feishu-main", + ChatID: "oc_1", + Content: "", + }) + + if ch.lastMsg.ChatID != "" { + t.Fatalf("empty content without routing metadata should be skipped, got: %+v", ch.lastMsg) + } +}