From 9897dd77ed47d98a2d8093c56dce3f63c07a0b84 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 7 Mar 2026 23:08:13 +0700 Subject: [PATCH] fix(ws-media): use ContentSuffix to inject images into session before run.completed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous approach failed because: 1. PublishOutbound with channel "ws" is silently dropped (no handler) 2. LLM strips image URLs despite instruction text 3. Post-run AddMessage races with frontend loadHistory() New approach: ContentSuffix field on RunRequest is appended to the assistant response inside the agent loop BEFORE saving to session and BEFORE emitting run.completed. This guarantees image markdown is in the session when frontend calls loadHistory(). Only affects WS channel — other channels still use ForwardMedia and their respective outbound handlers. --- cmd/gateway_consumer.go | 56 +++++++++++++----------------------- internal/agent/loop.go | 5 ++++ internal/agent/loop_types.go | 5 ++-- 3 files changed, 28 insertions(+), 38 deletions(-) diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 7ebbf735..b64161b3 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -425,25 +425,21 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents outMeta := buildAnnounceOutMeta(origLocalKey) // Build request before goroutine to capture msg fields. - // WS: convert media to markdown URLs inline (no outbound channel handler). - // Must happen BEFORE the run so the agent loop saves images in the session - // before run.completed fires — avoids race with frontend loadHistory(). + // WS channel has no outbound handler — media converted to markdown URLs + // and appended to the assistant response via ContentSuffix, which the + // agent loop applies BEFORE saving to session and emitting run.completed. fwdMedia := msg.Media - announceMessage := msg.Content - wsMediaSuffix := "" + contentSuffix := "" if origChannel == "ws" && len(msg.Media) > 0 { - mdMedia := mediaToMarkdownFromPaths(msg.Media, cfg.WorkspacePath(), cfg) - if mdMedia != "" { - announceMessage += "\n\n[Image URLs for web display — include these EXACTLY as-is at the end of your response]" + mdMedia - wsMediaSuffix = mdMedia - } - fwdMedia = nil // already embedded as markdown + contentSuffix = mediaToMarkdownFromPaths(msg.Media, cfg.WorkspacePath(), cfg) + fwdMedia = nil // WS: images delivered via ContentSuffix, not ForwardMedia } announceReq := agent.RunRequest{ SessionKey: sessionKey, - Message: announceMessage, + Message: msg.Content, ForwardMedia: fwdMedia, + ContentSuffix: contentSuffix, Channel: origChannel, ChatID: msg.ChatID, PeerKind: origPeerKind, @@ -459,7 +455,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents // Handle announce asynchronously with per-session serialization. // The mutex ensures concurrent announces for the same session wait for // each other, so each reads up-to-date session history. - go func(sessionKey, origCh, chatID, senderID, label string, meta map[string]string, req agent.RunRequest, mediaSuffix string) { + go func(sessionKey, origCh, chatID, senderID, label string, meta map[string]string, req agent.RunRequest) { mu := getAnnounceMu(sessionKey) mu.Lock() defer mu.Unlock() @@ -497,11 +493,6 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents announceContent = "" // suppress NO_REPLY text but still send media } - // Fallback: if LLM stripped the image markdown URLs, append them. - if mediaSuffix != "" && !strings.Contains(announceContent, mediaSuffix) { - announceContent += mediaSuffix - } - outMsg := bus.OutboundMessage{ Channel: origCh, ChatID: chatID, @@ -515,7 +506,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents }) } msgBus.PublishOutbound(outMsg) - }(sessionKey, origChannel, msg.ChatID, msg.SenderID, msg.Metadata["subagent_label"], outMeta, announceReq, wsMediaSuffix) + }(sessionKey, origChannel, msg.ChatID, msg.SenderID, msg.Metadata["subagent_label"], outMeta, announceReq) continue } @@ -569,23 +560,20 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents // Build outbound metadata for topic/thread routing. outMeta := buildAnnounceOutMeta(origLocalKey) - // WS: convert media to markdown URLs inline (same as subagent announce). + // WS channel has no outbound handler — media injected into session after run. + // WS channel has no outbound handler — media delivered via ContentSuffix. fwdMedia := msg.Media - announceMessage := msg.Content - wsMediaSuffix := "" + contentSuffix := "" if origChannel == "ws" && len(msg.Media) > 0 { - mdMedia := mediaToMarkdownFromPaths(msg.Media, cfg.WorkspacePath(), cfg) - if mdMedia != "" { - announceMessage += "\n\n[Image URLs for web display — include these EXACTLY as-is at the end of your response]" + mdMedia - wsMediaSuffix = mdMedia - } - fwdMedia = nil + contentSuffix = mediaToMarkdownFromPaths(msg.Media, cfg.WorkspacePath(), cfg) + fwdMedia = nil // WS: images delivered via ContentSuffix, not ForwardMedia } announceReq := agent.RunRequest{ SessionKey: sessionKey, - Message: announceMessage, + Message: msg.Content, ForwardMedia: fwdMedia, + ContentSuffix: contentSuffix, Channel: origChannel, ChatID: msg.ChatID, PeerKind: origPeerKind, @@ -600,7 +588,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents } // Same per-session serialization as subagent announce above. - go func(sessionKey, origCh, chatID, senderID string, meta map[string]string, req agent.RunRequest, mediaSuffix string) { + go func(sessionKey, origCh, chatID, senderID string, meta map[string]string, req agent.RunRequest) { mu := getAnnounceMu(sessionKey) mu.Lock() defer mu.Unlock() @@ -626,16 +614,12 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents slog.Info("delegate announce: suppressed silent/empty reply", "delegation", senderID) return } + announceContent := outcome.Result.Content if isSilent { announceContent = "" // suppress NO_REPLY text but still send media } - // Fallback: if LLM stripped the image markdown URLs, append them. - if mediaSuffix != "" && !strings.Contains(announceContent, mediaSuffix) { - announceContent += mediaSuffix - } - outMsg := bus.OutboundMessage{ Channel: origCh, ChatID: chatID, @@ -649,7 +633,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents }) } msgBus.PublishOutbound(outMsg) - }(sessionKey, origChannel, msg.ChatID, msg.SenderID, outMeta, announceReq, wsMediaSuffix) + }(sessionKey, origChannel, msg.ChatID, msg.SenderID, outMeta, announceReq) continue } diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 868386eb..f8436dc5 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -877,6 +877,11 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } } + // Append content suffix (e.g. image markdown for WS) before saving to session. + if req.ContentSuffix != "" && !strings.Contains(finalContent, req.ContentSuffix) { + finalContent += req.ContentSuffix + } + pendingMsgs = append(pendingMsgs, providers.Message{ Role: "assistant", Content: finalContent, diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 14718d87..b7b9300f 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -276,8 +276,9 @@ type RunRequest struct { MaxIterations int // per-request override (0 = use agent default, must be lower) // Run classification - RunKind string // "delegation", "announce" — empty for user-initiated runs - HideInput bool // don't persist input message in session history (announce runs) + RunKind string // "delegation", "announce" — empty for user-initiated runs + HideInput bool // don't persist input message in session history (announce runs) + ContentSuffix string // appended to assistant response before saving (e.g. image markdown for WS) // Delegation context (set when running as a delegate agent) DelegationID string // delegation ID for event correlation