From 23556d7b67c2008971e240137781b01872851bec Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:01:21 +0900 Subject: [PATCH] fix: finalize draft as permanent message on task completion Telegram sendMessageDraft creates ephemeral drafts that disappear after a short time. Task completion messages were sent via SendDraft (through the IsTaskStatus path), so the result would show briefly then vanish. Fix: Add Final flag to OutboundMessage. When set, handleTaskStatusSend skips SendDraft and sends via SendWithID/Send instead, creating a permanent message. The completion defer sets Final: true. Also captures message tool content in activeTask.messageContent so the completion bubble includes the actual result (priority: message tool content > finalContent > task.Result). Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 56 ++++++++++++++++++++++++++++------------- pkg/bus/types.go | 1 + pkg/channels/manager.go | 16 ++++++++++++ 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index c5c83ca4a..1cf53a1cb 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -53,7 +53,8 @@ type activeTask struct { lastError *toolLogEntry // sticky: most recent error, persists across iterations projectDir string // detected from exec cd target (authoritative) fileCommonDir string // LCP of file paths relative to workspace (fallback) - streamedChunks bool // true after onChunk fires at least once + streamedChunks bool // true after onChunk fires at least once + messageContent string // last content sent by the message tool (for inclusion in completion) mu sync.Mutex } @@ -1063,15 +1064,36 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt if opts.TaskID != "" { elapsed := time.Since(task.StartedAt) completionMsg := fmt.Sprintf("\u2705 Task completed (%.1fs)", elapsed.Seconds()) - if finalContent != "" && finalContent != defaultResponse && finalContent != "HEARTBEAT_OK" { - // Keep completion + response in one bubble if short enough (4096 = Telegram limit). - // If too long, edit the status bubble with the header, then send the - // full response as a regular message — the channel worker's SplitMessage - // will automatically chunk it for channels with MaxMessageLength. - combined := completionMsg + "\n\n" + finalContent + + // Determine the best content to show in the completion bubble. + // Priority: message tool content > finalContent > task.Result + task.mu.Lock() + msgContent := task.messageContent + task.mu.Unlock() + + var resultContent string + switch { + case msgContent != "": + // The message tool already sent this to the user via the + // task bubble; re-include it so the completion doesn't erase it. + resultContent = msgContent + case finalContent != "" && finalContent != defaultResponse && finalContent != "HEARTBEAT_OK": + resultContent = finalContent + default: + summary := task.Result + if summary == "" { + summary = task.Description + } + resultContent = summary + } + + if resultContent != "" { + combined := completionMsg + "\n\n" + resultContent if len([]rune(combined)) <= 4096 { completionMsg = combined } else { + // Too long for one bubble: send header as task status, + // body as regular message (auto-split by SplitMessage). doneCtx, doneCancel := context.WithTimeout(context.Background(), 5*time.Second) _ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{ Channel: opts.Channel, @@ -1079,24 +1101,16 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt Content: completionMsg, IsTaskStatus: true, TaskID: opts.TaskID, + Final: true, }) _ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, - Content: finalContent, + Content: resultContent, }) doneCancel() return } - } else { - // No finalContent — fall back to task.Result or task.Description - summary := task.Result - if summary == "" { - summary = task.Description - } - if summary != "" { - completionMsg += "\n" + summary - } } doneCtx, doneCancel := context.WithTimeout(context.Background(), 5*time.Second) _ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{ @@ -1105,6 +1119,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt Content: completionMsg, IsTaskStatus: true, TaskID: opts.TaskID, + Final: true, }) doneCancel() } @@ -1135,6 +1150,13 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt if mt, ok := tool.(*tools.MessageTool); ok { taskID := opts.TaskID mt.SetSendCallback(func(channel, chatID, content string) error { + // Capture the message tool's content so the completion + // defer can include it instead of losing it to an overwrite. + if task != nil { + task.mu.Lock() + task.messageContent = content + task.mu.Unlock() + } pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() return al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 494e540d6..7a8ca8c12 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -36,6 +36,7 @@ type OutboundMessage struct { IsStatus bool `json:"is_status,omitempty"` IsTaskStatus bool `json:"is_task_status,omitempty"` TaskID string `json:"task_id,omitempty"` + Final bool `json:"final,omitempty"` // Finalize: send as permanent message, not draft SkipPlaceholder bool `json:"skip_placeholder,omitempty"` } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index a75cac3a0..76f207110 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -577,6 +577,22 @@ func (m *Manager) handleTaskStatusSend(ctx context.Context, name string, w *chan taskKey := msg.TaskID + // Final message: send as permanent (non-draft) message so it persists. + // Drafts are ephemeral and disappear after a short time; the completion + // message must survive. Clear the draft tracking and send via SendWithID + // or regular Send, which creates a permanent Telegram message. + if msg.Final { + m.taskMsgIDs.Delete(taskKey) + m.statusEditTimes.Delete(taskKey) + if sender, ok := w.ch.(MessageSenderWithID); ok { + if msgID, err := sender.SendWithID(ctx, msg.ChatID, msg.Content); err == nil && msgID != "" { + return + } + } + _ = w.ch.Send(ctx, msg) + return + } + // 0. Draft-based streaming (preferred for supported channels) if drafter, ok := w.ch.(DraftSender); ok && taskKey != "" { var did int