Merge pull request #20 from dj-oyu/fix/finalize-draft-on-completion

fix: finalize draft as permanent message on task completion
This commit is contained in:
dj-oyu 2026-03-03 12:13:27 +09:00 committed by GitHub
commit 27692c7db9
3 changed files with 55 additions and 16 deletions

View file

@ -54,6 +54,7 @@ type activeTask struct {
projectDir string // detected from exec cd target (authoritative) projectDir string // detected from exec cd target (authoritative)
fileCommonDir string // LCP of file paths relative to workspace (fallback) 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 mu sync.Mutex
} }
@ -1063,15 +1064,36 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
if opts.TaskID != "" { if opts.TaskID != "" {
elapsed := time.Since(task.StartedAt) elapsed := time.Since(task.StartedAt)
completionMsg := fmt.Sprintf("\u2705 Task completed (%.1fs)", elapsed.Seconds()) 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). // Determine the best content to show in the completion bubble.
// If too long, edit the status bubble with the header, then send the // Priority: message tool content > finalContent > task.Result
// full response as a regular message — the channel worker's SplitMessage task.mu.Lock()
// will automatically chunk it for channels with MaxMessageLength. msgContent := task.messageContent
combined := completionMsg + "\n\n" + finalContent 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 { if len([]rune(combined)) <= 4096 {
completionMsg = combined completionMsg = combined
} else { } 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) doneCtx, doneCancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{ _ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{
Channel: opts.Channel, Channel: opts.Channel,
@ -1079,24 +1101,16 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
Content: completionMsg, Content: completionMsg,
IsTaskStatus: true, IsTaskStatus: true,
TaskID: opts.TaskID, TaskID: opts.TaskID,
Final: true,
}) })
_ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{ _ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{
Channel: opts.Channel, Channel: opts.Channel,
ChatID: opts.ChatID, ChatID: opts.ChatID,
Content: finalContent, Content: resultContent,
}) })
doneCancel() doneCancel()
return 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) doneCtx, doneCancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{ _ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{
@ -1105,6 +1119,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
Content: completionMsg, Content: completionMsg,
IsTaskStatus: true, IsTaskStatus: true,
TaskID: opts.TaskID, TaskID: opts.TaskID,
Final: true,
}) })
doneCancel() doneCancel()
} }
@ -1135,6 +1150,13 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
if mt, ok := tool.(*tools.MessageTool); ok { if mt, ok := tool.(*tools.MessageTool); ok {
taskID := opts.TaskID taskID := opts.TaskID
mt.SetSendCallback(func(channel, chatID, content string) error { 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) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel() defer pubCancel()
return al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ return al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{

View file

@ -36,6 +36,7 @@ type OutboundMessage struct {
IsStatus bool `json:"is_status,omitempty"` IsStatus bool `json:"is_status,omitempty"`
IsTaskStatus bool `json:"is_task_status,omitempty"` IsTaskStatus bool `json:"is_task_status,omitempty"`
TaskID string `json:"task_id,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"` SkipPlaceholder bool `json:"skip_placeholder,omitempty"`
} }

View file

@ -577,6 +577,22 @@ func (m *Manager) handleTaskStatusSend(ctx context.Context, name string, w *chan
taskKey := msg.TaskID 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) // 0. Draft-based streaming (preferred for supported channels)
if drafter, ok := w.ch.(DraftSender); ok && taskKey != "" { if drafter, ok := w.ch.(DraftSender); ok && taskKey != "" {
var did int var did int