fix(ws-media): use ContentSuffix to inject images into session before run.completed

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.
This commit is contained in:
viettranx committed 2026-03-07 23:08:13 +07:00
1 parent 892ee8ea70
commit 9897dd77ed
3 files changed
+28 -38

No files matched your search

+20 -36
View File
@@ -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
}
+5
View File
@@ -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,
+3 -2
View File
@@ -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