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..ce05fb149 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -185,31 +185,34 @@ 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 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 !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])) + editMsg.ParseMode = telego.ModeHTML + + 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 { @@ -472,7 +475,9 @@ func (c *TelegramChannel) handleQuickCommand(ctx context.Context, message telego "peer_id": peerID, } - // No "Thinking..." placeholder — send directly via message bus + // 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 } diff --git a/pkg/channels/telegram_test.go b/pkg/channels/telegram_test.go index 6afcc1ff9..e4291e035 100644 --- a/pkg/channels/telegram_test.go +++ b/pkg/channels/telegram_test.go @@ -86,3 +86,13 @@ 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) + } +}