From fdbca6d56d5e8294d008dd00a9a49ac9cfe231dd Mon Sep 17 00:00:00 2001 From: Anton Bogdanovich <27antonb@gmail.com> Date: Wed, 6 May 2026 15:41:49 -0700 Subject: [PATCH] fix(telegram): clean up subagent tool feedback --- pkg/agent/adapters/channelmanager.go | 9 ++++ pkg/agent/interfaces/interfaces.go | 12 +++++ pkg/agent/subturn.go | 11 +++++ pkg/channels/manager.go | 36 +++++++++++++++ pkg/channels/telegram/telegram.go | 1 + pkg/channels/telegram/telegram_test.go | 62 ++++++++++++++++++++++++++ 6 files changed, 131 insertions(+) diff --git a/pkg/agent/adapters/channelmanager.go b/pkg/agent/adapters/channelmanager.go index ad0840e86..98eeaed59 100644 --- a/pkg/agent/adapters/channelmanager.go +++ b/pkg/agent/adapters/channelmanager.go @@ -49,3 +49,12 @@ func (a *channelManagerAdapter) DismissToolFeedback( ) { a.inner.DismissToolFeedback(ctx, channel, chatID, outboundCtx) } + +func (a *channelManagerAdapter) DismissToolFeedbackForSession( + ctx context.Context, + channel, chatID string, + outboundCtx *bus.InboundContext, + sessionKey string, +) { + a.inner.DismissToolFeedbackForSession(ctx, channel, chatID, outboundCtx, sessionKey) +} diff --git a/pkg/agent/interfaces/interfaces.go b/pkg/agent/interfaces/interfaces.go index 2efec05e1..829eaa75d 100644 --- a/pkg/agent/interfaces/interfaces.go +++ b/pkg/agent/interfaces/interfaces.go @@ -51,4 +51,16 @@ type ChannelManager interface { // outboundCtx carries topic/thread info needed for channels that use // scoped tracker keys (e.g., Telegram forum topics); may be nil. DismissToolFeedback(ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext) + + // DismissToolFeedbackForSession clears a session-scoped tool feedback + // message. This is used for background sub-turns whose progress messages + // are visible in the originating chat, but whose final result is delivered + // asynchronously through the parent turn instead of as a direct channel + // response. + DismissToolFeedbackForSession( + ctx context.Context, + channel, chatID string, + outboundCtx *bus.InboundContext, + sessionKey string, + ) } diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index 483c72842..fa9b356bd 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -447,6 +447,17 @@ func spawnSubTurn( // Result Delivery Strategy (Async vs Sync) if cfg.Async { + if al != nil && al.channelManager != nil && childTS.channel != "" { + dismissCtx, dismissCancel := context.WithTimeout(context.Background(), 5*time.Second) + al.channelManager.DismissToolFeedbackForSession( + dismissCtx, + childTS.channel, + childTS.chatID, + childTS.opts.InboundContext, + childID, + ) + dismissCancel() + } deliverSubTurnResult(al, parentTS, childID, result) } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 9d6ca543f..2c648842d 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -206,6 +206,32 @@ func dismissTrackedToolFeedbackMessage( } } +func dismissTrackedToolFeedbackMessageForSession( + ctx context.Context, + ch Channel, + chatID string, + outboundCtx *bus.InboundContext, + sessionKey string, +) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, outboundCtx) + return + } + trackedChatID := resolveOutboundChatID(ch, chatID, outboundCtx) + if trackedChatID == "" { + return + } + trackedChatID += "#session:" + sessionKey + if cleaner, ok := ch.(toolFeedbackMessageCleaner); ok { + cleaner.DismissToolFeedbackMessage(ctx, trackedChatID) + return + } + if tracker, ok := ch.(toolFeedbackMessageTracker); ok { + tracker.ClearToolFeedbackMessage(trackedChatID) + } +} + func clearTrackedToolFeedbackMessage( ch Channel, chatID string, @@ -235,6 +261,16 @@ func (m *Manager) DismissToolFeedback( dismissTrackedToolFeedbackMessage(ctx, ch, chatID, outboundCtx) } +func (m *Manager) DismissToolFeedbackForSession( + ctx context.Context, channelName, chatID string, outboundCtx *bus.InboundContext, sessionKey string, +) { + ch, ok := m.GetChannel(channelName) + if !ok { + return + } + dismissTrackedToolFeedbackMessageForSession(ctx, ch, chatID, outboundCtx, sessionKey) +} + func prepareToolFeedbackMessageContent(ch Channel, content string) string { prepared := strings.TrimSpace(content) if prepared == "" { diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index b490c1ad3..7983b6044 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -468,6 +468,7 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag // DeleteMessage implements channels.MessageDeleter. func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + chatID = telegramToolFeedbackDeliveryChatID(chatID) cid, _, err := parseTelegramChatID(chatID) if err != nil { return err diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 949e3074c..f62da0219 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -383,6 +383,7 @@ func TestSend_FinalReplyDoesNotFinalizeDifferentSessionToolFeedback(t *testing.T }, } ch := newTestChannel(t, caller) + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) baseCtx := bus.InboundContext{ Channel: "telegram", @@ -421,6 +422,67 @@ func TestSend_FinalReplyDoesNotFinalizeDifferentSessionToolFeedback(t *testing.T assert.Equal(t, "1", msgID) } +func TestSend_SessionScopedToolFeedbackUpdatesExistingTelegramMessage(t *testing.T) { + nextMessageID := 0 + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + nextMessageID++ + return successResponseWithMessageID(t, nextMessageID), nil + }, + } + ch := newTestChannel(t, caller) + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + + baseCtx := bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + TopicID: "42", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + } + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890", + SessionKey: "subturn-1", + Content: "Working...\n• tool: `read_file`", + Context: baseCtx, + }) + require.NoError(t, err) + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890", + SessionKey: "subturn-1", + Content: "Working...\n• tool: `read_file`\n• tool: `mcp_gpt_researcher_deep_research`", + Context: baseCtx, + }) + require.NoError(t, err) + require.Equal(t, []string{"1"}, ids) + require.Len(t, caller.calls, 2) + assert.Contains(t, caller.calls[1].URL, "editMessageText") + assert.NotContains(t, caller.calls[1].URL, "%23session") + assert.NotContains(t, caller.calls[1].URL, "#session") +} + +func TestDismissToolFeedbackMessage_SessionScopedKeyDeletesTelegramMessage(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + ch.RecordToolFeedbackMessage("-1001234567890/42#session:subturn-1", "7", "Working...\n• tool: `read_file`") + + ch.DismissToolFeedbackMessage(context.Background(), "-1001234567890/42#session:subturn-1") + + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "deleteMessage") + assert.NotContains(t, caller.calls[0].URL, "%23session") + assert.NotContains(t, caller.calls[0].URL, "#session") + _, ok := ch.currentToolFeedbackMessage("-1001234567890/42#session:subturn-1") + assert.False(t, ok) +} + func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { ch := newTestChannel(t, &stubCaller{ callFn: func(context.Context, string, *ta.RequestData) (*ta.Response, error) {