Merge pull request #1 from dj-oyu/copilot/fix-chat-bubble-update-issue
fix: slash commands no longer corrupt the real-time task status bubble (Telegram)
This commit is contained in:
commit
946a79423b
5 changed files with 93 additions and 25 deletions
|
|
@ -186,9 +186,10 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
if response, handled := al.handleCommand(ctx, msg); handled {
|
if response, handled := al.handleCommand(ctx, msg); handled {
|
||||||
if response != "" {
|
if response != "" {
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
Content: response,
|
Content: response,
|
||||||
|
SkipPlaceholder: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestBuildTaskReminder_Truncation(t *testing.T) {
|
||||||
// Build a long message (1000 runes)
|
// Build a long message (1000 runes)
|
||||||
longMsg := strings.Repeat("あ", 1000)
|
longMsg := strings.Repeat("あ", 1000)
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ type InboundMessage struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type OutboundMessage struct {
|
type OutboundMessage struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
IsStatus bool `json:"is_status,omitempty"`
|
IsStatus bool `json:"is_status,omitempty"`
|
||||||
|
SkipPlaceholder bool `json:"skip_placeholder,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageHandler func(InboundMessage) error
|
type MessageHandler func(InboundMessage) error
|
||||||
|
|
|
||||||
|
|
@ -185,31 +185,34 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
return fmt.Errorf("invalid chat ID: %w", 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)
|
cleanContent := sanitizeTelegramOutgoingContent(msg.Content)
|
||||||
chunks := utils.SplitMessage(cleanContent, telegramMaxMessageChars)
|
chunks := utils.SplitMessage(cleanContent, telegramMaxMessageChars)
|
||||||
if len(chunks) == 0 {
|
if len(chunks) == 0 {
|
||||||
chunks = []string{cleanContent}
|
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
|
firstChunkSent := false
|
||||||
if pID, ok := c.placeholders.Load(msg.ChatID); ok {
|
if !msg.SkipPlaceholder {
|
||||||
c.placeholders.Delete(msg.ChatID)
|
// Stop thinking animation
|
||||||
editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), markdownToTelegramHTML(chunks[0]))
|
if stop, ok := c.stopThinking.Load(msg.ChatID); ok {
|
||||||
editMsg.ParseMode = telego.ModeHTML
|
if cf, ok := stop.(*thinkingCancel); ok && cf != nil {
|
||||||
|
cf.Cancel()
|
||||||
if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
|
}
|
||||||
firstChunkSent = true
|
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 {
|
sendChunk := func(text string) error {
|
||||||
|
|
@ -472,7 +475,9 @@ func (c *TelegramChannel) handleQuickCommand(ctx context.Context, message telego
|
||||||
"peer_id": peerID,
|
"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)
|
c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, nil, metadata)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,3 +86,13 @@ func TestDisplayWidth_EmojiIsThree(t *testing.T) {
|
||||||
t.Fatalf("displayWidth(stars) = %d, want 15", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue