From ff543e15dfc81518a6499c888e8289a6005c084f Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Mon, 2 Mar 2026 03:37:40 +0900 Subject: [PATCH 1/2] fix: heartbeat completion shows LLM result instead of garbled streaming fragment Two issues with heartbeat (SendResponse: false) tasks: 1. The last streaming status bubble persists as a garbled fragment because no final non-status message triggers preSend cleanup. 2. The "Task completed" notification shows the heartbeat prompt instead of the LLM response. Add Result/streamedChunks fields to activeTask, store the LLM response summary after the loop, publish a clean status replacement for background tasks that streamed, and use task.Result in the completion notification. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 50 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index db406849b..1807bd6ce 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -42,17 +42,19 @@ import ( // activeTask tracks a running agent task for live status and intervention. type activeTask struct { - Description string - Iteration int - MaxIter int - StartedAt time.Time - cancel context.CancelFunc - interrupt chan string // buffered 1, for user message injection - toolLog []toolLogEntry - 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) - mu sync.Mutex + Description string + Result string // LLM response summary for completion notification + Iteration int + MaxIter int + StartedAt time.Time + cancel context.CancelFunc + interrupt chan string // buffered 1, for user message injection + toolLog []toolLogEntry + 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 + mu sync.Mutex } // toolLogEntry records a single tool call for the live terminal view. @@ -1054,11 +1056,15 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // Publish final task status on completion for background tasks if opts.TaskID != "" { elapsed := time.Since(task.StartedAt) + summary := task.Result + if summary == "" { + summary = task.Description + } doneCtx, doneCancel := context.WithTimeout(context.Background(), 5*time.Second) _ = al.bus.PublishOutbound(doneCtx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, - Content: fmt.Sprintf("\u2705 Task completed (%.1fs)\n%s", elapsed.Seconds(), task.Description), + Content: fmt.Sprintf("\u2705 Task completed (%.1fs)\n%s", elapsed.Seconds(), summary), IsTaskStatus: true, TaskID: opts.TaskID, }) @@ -1343,6 +1349,23 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt finalContent = opts.DefaultResponse } + // 5d. Store result summary for task completion notification + if task != nil { + task.Result = utils.Truncate(finalContent, 280) + } + + // 5e. Replace orphaned streaming status bubble for background tasks. + // When SendResponse is false (e.g. heartbeat), no final non-status message + // triggers cleanup, so the last streaming chunk persists on Telegram. + if opts.Background && !opts.SendResponse && !constants.IsInternalChannel(opts.Channel) && task != nil && task.streamedChunks { + _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: utils.Truncate(finalContent, 200), + IsStatus: true, + }) + } + // 6. Save final assistant message to session (deferred write-behind) agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent) agent.Sessions.MarkDirty(opts.SessionKey) @@ -2175,6 +2198,9 @@ func (al *AgentLoop) runLLMIteration( if !constants.IsInternalChannel(opts.Channel) { lastPublish := time.Time{} onChunk = func(accumulated, reasoning string) { + if task != nil { + task.streamedChunks = true + } if time.Since(lastPublish) < 500*time.Millisecond { return } From 428f63a7527f2950baf012ca6ea8fb82eaf1f82f Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:01:24 +0900 Subject: [PATCH 2/2] fix: break long line to satisfy golines (120 char limit) Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 1807bd6ce..a052da4ea 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1357,7 +1357,8 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 5e. Replace orphaned streaming status bubble for background tasks. // When SendResponse is false (e.g. heartbeat), no final non-status message // triggers cleanup, so the last streaming chunk persists on Telegram. - if opts.Background && !opts.SendResponse && !constants.IsInternalChannel(opts.Channel) && task != nil && task.streamedChunks { + if opts.Background && !opts.SendResponse && !constants.IsInternalChannel(opts.Channel) && task != nil && + task.streamedChunks { _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID,