fix(pipeline): deliver generated images to chat channels, not just session history (#1351)

Codex native image_generation output (state.Observe.AssistantImages) was
persisted to workspace/media/ and attached to the assistant message's
MediaRefs for session history, but never added to state.Tool.MediaResults
— the only field that flows into RunResult.Media and from there into the
outbound channel message. Generated images showed up in the web UI (which
reads session history) but chat channels (Telegram, Zalo, Slack, ...)
received text-only replies with no attachment.

Append the persisted image refs to state.Tool.MediaResults right after
they're attached to the assistant message, so they reach RunResult.Media
via the existing pipeline plumbing and cmd/gateway_consumer_normal.go's
appendMediaToOutbound — the same path create_image/send_file already use.
No new delivery path needed.
This commit is contained in:
Duc Nguyen authored and GitHub committed 2026-07-05 01:26:40 +07:00
1 parent 016263eb84
commit b9a69e355e
2 files changed
+100

No files matched your search

+11
View File
@@ -109,6 +109,17 @@ func (s *FinalizeStage) Execute(ctx context.Context, state *RunState) error {
assistantMsg.MediaRefs = append(assistantMsg.MediaRefs, assistantImageRefs...)
state.Messages.AppendPending(assistantMsg)
// Surface generated images (Codex image_generation_call) on MediaResults
// too, so they reach RunResult.Media / outbound channel delivery — not just
// session history (MediaRefs were already appended to the message above).
for _, ref := range assistantImageRefs {
mr := MediaResult{Path: ref.Path, ContentType: ref.MimeType, Prompt: ref.Prompt}
if info, statErr := os.Stat(ref.Path); statErr == nil {
mr.Size = info.Size()
}
state.Tool.MediaResults = append(state.Tool.MediaResults, mr)
}
// 4. Flush remaining pending messages to session store
historyCountBeforeFlush := len(state.Messages.History())
pending := state.Messages.FlushPending()
@@ -0,0 +1,89 @@
package pipeline
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// A model-generated image (Codex native image_generation) must reach
// RunResult.Media — not just the assistant message's MediaRefs — otherwise
// chat channels never receive it (only the web UI reads session history).
func TestFinalizeStage_GeneratedImageReachesMediaResults(t *testing.T) {
t.Parallel()
dir := t.TempDir()
imgPath := filepath.Join(dir, "generated.png")
if err := os.WriteFile(imgPath, []byte("fake-png-bytes"), 0644); err != nil {
t.Fatal(err)
}
deps := &PipelineDeps{
FlushMessages: func(context.Context, string, []providers.Message) error { return nil },
UpdateMetadata: func(context.Context, string, providers.Usage, int) error { return nil },
// Simulates persistAssistantImages: real implementation decodes base64,
// writes to workspace/media/, and appends MediaRefs to msg.
PersistAssistantImages: func(msg *providers.Message, _ string) {
msg.MediaRefs = append(msg.MediaRefs, providers.MediaRef{
ID: "img-1",
MimeType: "image/png",
Kind: "image",
Path: imgPath,
Prompt: "a luxury jewelry poster",
})
msg.Images = nil
},
}
stage := NewFinalizeStage(deps)
state := defaultState()
state.Observe.FinalContent = "Here's the poster."
state.Observe.AssistantImages = []providers.ImageContent{
{MimeType: "image/png", Data: "ZmFrZS1wbmctYnl0ZXM="},
}
if err := stage.Execute(context.Background(), state); err != nil {
t.Fatalf("Execute() error: %v", err)
}
if len(state.Tool.MediaResults) != 1 {
t.Fatalf("MediaResults = %d entries, want 1 (generated image missing from outbound media)", len(state.Tool.MediaResults))
}
got := state.Tool.MediaResults[0]
if got.Path != imgPath {
t.Errorf("MediaResults[0].Path = %q, want %q", got.Path, imgPath)
}
if got.ContentType != "image/png" {
t.Errorf("MediaResults[0].ContentType = %q, want image/png", got.ContentType)
}
if got.Prompt != "a luxury jewelry poster" {
t.Errorf("MediaResults[0].Prompt = %q, want the generation prompt", got.Prompt)
}
if got.Size != int64(len("fake-png-bytes")) {
t.Errorf("MediaResults[0].Size = %d, want %d (stat'd from disk)", got.Size, len("fake-png-bytes"))
}
}
// No generated images → no spurious MediaResults entries.
func TestFinalizeStage_NoGeneratedImagesNoMediaResults(t *testing.T) {
t.Parallel()
deps := &PipelineDeps{
FlushMessages: func(context.Context, string, []providers.Message) error { return nil },
UpdateMetadata: func(context.Context, string, providers.Usage, int) error { return nil },
}
stage := NewFinalizeStage(deps)
state := defaultState()
state.Observe.FinalContent = "just text, no image"
if err := stage.Execute(context.Background(), state); err != nil {
t.Fatalf("Execute() error: %v", err)
}
if len(state.Tool.MediaResults) != 0 {
t.Fatalf("MediaResults = %d entries, want 0", len(state.Tool.MediaResults))
}
}