From 86b5baccf5ca4494c26010d33fc1abbf1febce2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:58:31 +0000 Subject: [PATCH 1/5] Initial plan From ee47513b4d37d2e48296e30a2a5c9dc0607e0eaf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:05:50 +0000 Subject: [PATCH 2/5] fix: slash command responses no longer overwrite the ongoing task status bubble Co-authored-by: dj-oyu <68707227+dj-oyu@users.noreply.github.com> --- pkg/agent/loop.go | 7 +++--- pkg/agent/loop_test.go | 51 ++++++++++++++++++++++++++++++++++++++++ pkg/bus/types.go | 9 +++---- pkg/channels/telegram.go | 16 +++++++------ 4 files changed, 69 insertions(+), 14 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 87d72b116..879db8d45 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -186,9 +186,10 @@ func (al *AgentLoop) Run(ctx context.Context) error { if response, handled := al.handleCommand(ctx, msg); handled { if response != "" { al.bus.PublishOutbound(bus.OutboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - Content: response, + Channel: msg.Channel, + ChatID: msg.ChatID, + Content: response, + SkipPlaceholder: true, }) } continue diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 8abc2f142..fb8de7a15 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -798,6 +798,57 @@ func TestResolveProvider_EmptyNameReturnsFallback(t *testing.T) { } } +// TestSlashCommandResponseSkipsPlaceholder verifies that slash command responses +// are published with SkipPlaceholder=true so they don't overwrite the ongoing task +// status bubble. +func TestSlashCommandResponseSkipsPlaceholder(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + go func() { + _ = al.Run(ctx) + }() + + // Send a slash command + msgBus.PublishInbound(bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/todo", + }) + + // Read the outbound message + outMsg, ok := msgBus.SubscribeOutbound(ctx) + if !ok { + t.Fatal("expected outbound message from slash command") + } + + if !outMsg.SkipPlaceholder { + t.Errorf("expected SkipPlaceholder=true for slash command response, got false") + } +} + func TestBuildTaskReminder_Truncation(t *testing.T) { // Build a long message (1000 runes) longMsg := strings.Repeat("あ", 1000) diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 84f9458ed..debe1fc57 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -11,10 +11,11 @@ type InboundMessage struct { } type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - IsStatus bool `json:"is_status,omitempty"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + IsStatus bool `json:"is_status,omitempty"` + SkipPlaceholder bool `json:"skip_placeholder,omitempty"` } type MessageHandler func(InboundMessage) error diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 130e1812c..abcc941ba 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -201,15 +201,17 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // Try to edit placeholder firstChunkSent := false - if pID, ok := c.placeholders.Load(msg.ChatID); ok { - c.placeholders.Delete(msg.ChatID) - editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), markdownToTelegramHTML(chunks[0])) - editMsg.ParseMode = telego.ModeHTML + if !msg.SkipPlaceholder { + if pID, ok := c.placeholders.Load(msg.ChatID); ok { + c.placeholders.Delete(msg.ChatID) + editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), markdownToTelegramHTML(chunks[0])) + editMsg.ParseMode = telego.ModeHTML - if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { - firstChunkSent = true + if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { + firstChunkSent = true + } + // Fallback to new message if edit fails } - // Fallback to new message if edit fails } sendChunk := func(text string) error { From cde47cce056fbcbcf5a629474e17894431bdd0dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:41:18 +0000 Subject: [PATCH 3/5] fix: guard stopThinking cleanup inside !SkipPlaceholder in Telegram Send() Co-authored-by: dj-oyu <68707227+dj-oyu@users.noreply.github.com> --- pkg/channels/telegram.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index abcc941ba..8e4afc64a 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -185,23 +185,24 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("invalid chat ID: %w", err) } - // Stop thinking animation - if stop, ok := c.stopThinking.Load(msg.ChatID); ok { - if cf, ok := stop.(*thinkingCancel); ok && cf != nil { - cf.Cancel() - } - c.stopThinking.Delete(msg.ChatID) - } - cleanContent := sanitizeTelegramOutgoingContent(msg.Content) chunks := utils.SplitMessage(cleanContent, telegramMaxMessageChars) if len(chunks) == 0 { chunks = []string{cleanContent} } - // Try to edit placeholder + // Slash-command responses (SkipPlaceholder=true) must not touch the + // stopThinking/placeholders state that belongs to the ongoing LLM task. firstChunkSent := false if !msg.SkipPlaceholder { + // Stop thinking animation + if stop, ok := c.stopThinking.Load(msg.ChatID); ok { + if cf, ok := stop.(*thinkingCancel); ok && cf != nil { + cf.Cancel() + } + c.stopThinking.Delete(msg.ChatID) + } + if pID, ok := c.placeholders.Load(msg.ChatID); ok { c.placeholders.Delete(msg.ChatID) editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), markdownToTelegramHTML(chunks[0])) From 031f0d27210d9e7034400c96b86601ac0a45cd86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:57:21 +0000 Subject: [PATCH 4/5] fix: slash-command responses use a message-scoped chatID for structural isolation Co-authored-by: dj-oyu <68707227+dj-oyu@users.noreply.github.com> --- pkg/channels/telegram.go | 11 +++++++++-- pkg/channels/telegram_test.go | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 8e4afc64a..780c94b98 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -475,8 +475,10 @@ func (c *TelegramChannel) handleQuickCommand(ctx context.Context, message telego "peer_id": peerID, } - // No "Thinking..." placeholder — send directly via message bus - c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, nil, metadata) + // Use a message-scoped chatID so this response never matches the ongoing + // LLM task's placeholder or stopThinking entry for this chat. + chatIDStr := fmt.Sprintf("%d#%d", chatID, message.MessageID) + c.HandleMessage(fmt.Sprintf("%d", user.ID), chatIDStr, content, nil, metadata) return nil } @@ -520,6 +522,11 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) } func parseChatID(chatIDStr string) (int64, error) { + // Strip message-scoped suffix used by quick-command responses. + // e.g. "123456789#42" → parse as "123456789" + if idx := strings.IndexByte(chatIDStr, '#'); idx >= 0 { + chatIDStr = chatIDStr[:idx] + } var id int64 _, err := fmt.Sscanf(chatIDStr, "%d", &id) return id, err diff --git a/pkg/channels/telegram_test.go b/pkg/channels/telegram_test.go index 6afcc1ff9..34cf84650 100644 --- a/pkg/channels/telegram_test.go +++ b/pkg/channels/telegram_test.go @@ -86,3 +86,25 @@ func TestDisplayWidth_EmojiIsThree(t *testing.T) { t.Fatalf("displayWidth(stars) = %d, want 15", got) } } + +func TestParseChatID_Plain(t *testing.T) { + id, err := parseChatID("123456789") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != 123456789 { + t.Errorf("expected 123456789, got %d", id) + } +} + +func TestParseChatID_MessageScopedSuffix(t *testing.T) { + // Quick-command responses use "chatID#messageID" to ensure uniqueness. + // parseChatID must strip the suffix so delivery still works. + id, err := parseChatID("123456789#42") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != 123456789 { + t.Errorf("expected 123456789, got %d", id) + } +} From ae347891a27d3538a5e329be4c09fab1e81b9450 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:10:02 +0000 Subject: [PATCH 5/5] fix: remove confusing synthetic chatID suffix, rely solely on SkipPlaceholder flag Co-authored-by: dj-oyu <68707227+dj-oyu@users.noreply.github.com> --- pkg/channels/telegram.go | 13 ++++--------- pkg/channels/telegram_test.go | 12 ------------ 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 780c94b98..ce05fb149 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -475,10 +475,10 @@ func (c *TelegramChannel) handleQuickCommand(ctx context.Context, message telego "peer_id": peerID, } - // Use a message-scoped chatID so this response never matches the ongoing - // LLM task's placeholder or stopThinking entry for this chat. - chatIDStr := fmt.Sprintf("%d#%d", chatID, message.MessageID) - c.HandleMessage(fmt.Sprintf("%d", user.ID), chatIDStr, content, nil, metadata) + // No "Thinking..." placeholder — send directly via message bus. + // SkipPlaceholder=true on the outbound message prevents Send() from + // touching the ongoing task's stopThinking/placeholders state. + c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, nil, metadata) return nil } @@ -522,11 +522,6 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) } func parseChatID(chatIDStr string) (int64, error) { - // Strip message-scoped suffix used by quick-command responses. - // e.g. "123456789#42" → parse as "123456789" - if idx := strings.IndexByte(chatIDStr, '#'); idx >= 0 { - chatIDStr = chatIDStr[:idx] - } var id int64 _, err := fmt.Sscanf(chatIDStr, "%d", &id) return id, err diff --git a/pkg/channels/telegram_test.go b/pkg/channels/telegram_test.go index 34cf84650..e4291e035 100644 --- a/pkg/channels/telegram_test.go +++ b/pkg/channels/telegram_test.go @@ -96,15 +96,3 @@ func TestParseChatID_Plain(t *testing.T) { t.Errorf("expected 123456789, got %d", id) } } - -func TestParseChatID_MessageScopedSuffix(t *testing.T) { - // Quick-command responses use "chatID#messageID" to ensure uniqueness. - // parseChatID must strip the suffix so delivery still works. - id, err := parseChatID("123456789#42") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if id != 123456789 { - t.Errorf("expected 123456789, got %d", id) - } -}