From b9a69e355e303da74691038bfa31b531be596f26 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <111303583+Buns0705@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:26:40 +0700 Subject: [PATCH] fix(pipeline): deliver generated images to chat channels, not just session history (#1351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/pipeline/finalize_stage.go | 11 +++ .../finalize_stage_image_delivery_test.go | 89 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 internal/pipeline/finalize_stage_image_delivery_test.go diff --git a/internal/pipeline/finalize_stage.go b/internal/pipeline/finalize_stage.go index f58975bd..21b9bec6 100644 --- a/internal/pipeline/finalize_stage.go +++ b/internal/pipeline/finalize_stage.go @@ -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() diff --git a/internal/pipeline/finalize_stage_image_delivery_test.go b/internal/pipeline/finalize_stage_image_delivery_test.go new file mode 100644 index 00000000..cea4d06e --- /dev/null +++ b/internal/pipeline/finalize_stage_image_delivery_test.go @@ -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)) + } +}