detached ecb3b02

fix(provider): deliver tool-result images to the OpenAI Responses route

On the openai-codex (Responses API) route a tool result serialized to a
string-only function_call_output, dropping ImageBlock content, and the
agent loop's tool-image mirror only fired for provider "openai". So
images returned by read reached the TUI but never the model, which then
correctly reported it received no image content.

Extend the mirror to fire for "openai-codex" too (its client already
serializes user-message images as input_image, so the bytes arrive),
and have the codex tool-result serializer emit a short placeholder for
an image-only result instead of an empty output the API may reject.
Adds a test covering both behaviors.
commit
ecb3b022cc7c88f89a069e6e6789806ae7bf7a2b
author
patriceckhart · · 2026-05-29 12:21 UTC
parent
124d679
signature
unsigned
refs
tag: v0.2.3
3 files · +92 -4
M packages/core/agent.go +7 -1
@@ -356,8 +356,14 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
356356 // This keeps the transcript self-contained for providers that can
357357 // see image blocks in tool messages while making OpenAI vision
358358 // models actually receive the image bytes.
359+ //
360+ // The OpenAI Responses route ("openai-codex") has the same
361+ // text-centric tool-output shape: a function_call_output only
362+ // carries a string, so images in a tool result never reach the
363+ // model. Both providers serialize images correctly when they
364+ // arrive as user content, so the mirror covers them both.
359365 var imageMirror provider.Message
360- if a.Client != nil && a.Client.Name() == "openai" {
366+ if a.Client != nil && (a.Client.Name() == "openai" || a.Client.Name() == "openai-codex") {
361367 if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 {
362368 a.messages = append(a.messages, mirror)
363369 a.rev++
@@ -356,8 +356,14 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
356 // This keeps the transcript self-contained for providers that can356 // This keeps the transcript self-contained for providers that can
357 // see image blocks in tool messages while making OpenAI vision357 // see image blocks in tool messages while making OpenAI vision
358 // models actually receive the image bytes.358 // models actually receive the image bytes.
359+ //
360+ // The OpenAI Responses route ("openai-codex") has the same
361+ // text-centric tool-output shape: a function_call_output only
362+ // carries a string, so images in a tool result never reach the
363+ // model. Both providers serialize images correctly when they
364+ // arrive as user content, so the mirror covers them both.
359 var imageMirror provider.Message365 var imageMirror provider.Message
360- if a.Client != nil && a.Client.Name() == "openai" {366+ if a.Client != nil && (a.Client.Name() == "openai" || a.Client.Name() == "openai-codex") {
361 if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 {367 if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 {
362 a.messages = append(a.messages, mirror)368 a.messages = append(a.messages, mirror)
363 a.rev++369 a.rev++
M packages/provider/openai_codex.go +22 -3
@@ -262,19 +262,38 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
262262 for _, c := range msg.Content {
263263 if tr, ok := c.(ToolResultBlock); ok {
264264 var text strings.Builder
265+ imageCount := 0
265266 for _, inner := range tr.Content {
266- if tb, ok := inner.(TextBlock); ok {
267+ switch v := inner.(type) {
268+ case TextBlock:
267269 if text.Len() > 0 {
268270 text.WriteString("\n")
269271 }
270- text.WriteString(tb.Text)
272+ text.WriteString(v.Text)
273+ case ImageBlock:
274+ imageCount++
275+ }
276+ }
277+ // The Responses API function_call_output only carries a
278+ // string, so image bytes cannot ride along here. The agent
279+ // loop mirrors any tool-result images into the following
280+ // user message (which this client does serialize as
281+ // input_image). Leave a short text note so an image-only
282+ // result is not an empty output the API may reject, and so
283+ // the model knows the image arrives next.
284+ out := text.String()
285+ if out == "" && imageCount > 0 {
286+ if imageCount == 1 {
287+ out = "[image returned; see the following message]"
288+ } else {
289+ out = fmt.Sprintf("[%d images returned; see the following message]", imageCount)
271290 }
272291 }
273292 callID, _ := splitCallID(tr.CallID)
274293 body.Input = append(body.Input, codexFunctionCallOutput{
275294 Type: "function_call_output",
276295 CallID: callID,
277- Output: text.String(),
296+ Output: out,
278297 })
279298 }
280299 }
@@ -262,19 +262,38 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
262 for _, c := range msg.Content {262 for _, c := range msg.Content {
263 if tr, ok := c.(ToolResultBlock); ok {263 if tr, ok := c.(ToolResultBlock); ok {
264 var text strings.Builder264 var text strings.Builder
265+ imageCount := 0
265 for _, inner := range tr.Content {266 for _, inner := range tr.Content {
266- if tb, ok := inner.(TextBlock); ok {267+ switch v := inner.(type) {
268+ case TextBlock:
267 if text.Len() > 0 {269 if text.Len() > 0 {
268 text.WriteString("\n")270 text.WriteString("\n")
269 }271 }
270- text.WriteString(tb.Text)272+ text.WriteString(v.Text)
273+ case ImageBlock:
274+ imageCount++
275+ }
276+ }
277+ // The Responses API function_call_output only carries a
278+ // string, so image bytes cannot ride along here. The agent
279+ // loop mirrors any tool-result images into the following
280+ // user message (which this client does serialize as
281+ // input_image). Leave a short text note so an image-only
282+ // result is not an empty output the API may reject, and so
283+ // the model knows the image arrives next.
284+ out := text.String()
285+ if out == "" && imageCount > 0 {
286+ if imageCount == 1 {
287+ out = "[image returned; see the following message]"
288+ } else {
289+ out = fmt.Sprintf("[%d images returned; see the following message]", imageCount)
271 }290 }
272 }291 }
273 callID, _ := splitCallID(tr.CallID)292 callID, _ := splitCallID(tr.CallID)
274 body.Input = append(body.Input, codexFunctionCallOutput{293 body.Input = append(body.Input, codexFunctionCallOutput{
275 Type: "function_call_output",294 Type: "function_call_output",
276 CallID: callID,295 CallID: callID,
277- Output: text.String(),296+ Output: out,
278 })297 })
279 }298 }
280 }299 }
A packages/provider/openai_codex_test.go +63 -0
@@ -0,0 +1,63 @@
1+package provider
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// An image-only tool result must not serialize to an empty
9+// function_call_output (the Responses API may reject it) and a
10+// following user-message image must serialize as input_image so the
11+// model actually receives the bytes.
12+func TestCodexImageToolResultMirror(t *testing.T) {
13+ c := NewOpenAICodex("token", "acct", "").(*codexClient)
14+
15+ wire, err := c.buildRequest(Request{
16+ Model: "gpt-5.5",
17+ Messages: []Message{
18+ {Role: RoleUser, Content: []Content{TextBlock{Text: "look at this"}}},
19+ {Role: RoleAssistant, Content: []Content{
20+ ToolCallBlock{ID: "call_1", Name: "read", Arguments: []byte(`{"path":"x.png"}`)},
21+ }},
22+ {Role: RoleTool, Content: []Content{
23+ ToolResultBlock{CallID: "call_1", Content: []Content{
24+ ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")},
25+ }},
26+ }},
27+ // The agent loop appends this mirror after an image tool result.
28+ {Role: RoleUser, Content: []Content{
29+ TextBlock{Text: "Tool output included the following image content:"},
30+ ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")},
31+ }},
32+ },
33+ })
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+
38+ var sawFnOutput, sawInputImage bool
39+ for _, item := range wire.Input {
40+ switch v := item.(type) {
41+ case codexFunctionCallOutput:
42+ sawFnOutput = true
43+ if strings.TrimSpace(v.Output) == "" {
44+ t.Fatalf("image-only tool result produced empty function_call_output")
45+ }
46+ if !strings.Contains(strings.ToLower(v.Output), "image") {
47+ t.Fatalf("placeholder should mention image, got %q", v.Output)
48+ }
49+ case codexInputMessage:
50+ for _, ct := range v.Content {
51+ if img, ok := ct.(codexInputImage); ok && img.Type == "input_image" {
52+ sawInputImage = true
53+ }
54+ }
55+ }
56+ }
57+ if !sawFnOutput {
58+ t.Fatalf("no function_call_output emitted")
59+ }
60+ if !sawInputImage {
61+ t.Fatalf("mirrored user image was not serialized as input_image")
62+ }
63+}
@@ -0,0 +1,63 @@
1+package provider
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// An image-only tool result must not serialize to an empty
9+// function_call_output (the Responses API may reject it) and a
10+// following user-message image must serialize as input_image so the
11+// model actually receives the bytes.
12+func TestCodexImageToolResultMirror(t *testing.T) {
13+ c := NewOpenAICodex("token", "acct", "").(*codexClient)
14+
15+ wire, err := c.buildRequest(Request{
16+ Model: "gpt-5.5",
17+ Messages: []Message{
18+ {Role: RoleUser, Content: []Content{TextBlock{Text: "look at this"}}},
19+ {Role: RoleAssistant, Content: []Content{
20+ ToolCallBlock{ID: "call_1", Name: "read", Arguments: []byte(`{"path":"x.png"}`)},
21+ }},
22+ {Role: RoleTool, Content: []Content{
23+ ToolResultBlock{CallID: "call_1", Content: []Content{
24+ ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")},
25+ }},
26+ }},
27+ // The agent loop appends this mirror after an image tool result.
28+ {Role: RoleUser, Content: []Content{
29+ TextBlock{Text: "Tool output included the following image content:"},
30+ ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")},
31+ }},
32+ },
33+ })
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+
38+ var sawFnOutput, sawInputImage bool
39+ for _, item := range wire.Input {
40+ switch v := item.(type) {
41+ case codexFunctionCallOutput:
42+ sawFnOutput = true
43+ if strings.TrimSpace(v.Output) == "" {
44+ t.Fatalf("image-only tool result produced empty function_call_output")
45+ }
46+ if !strings.Contains(strings.ToLower(v.Output), "image") {
47+ t.Fatalf("placeholder should mention image, got %q", v.Output)
48+ }
49+ case codexInputMessage:
50+ for _, ct := range v.Content {
51+ if img, ok := ct.(codexInputImage); ok && img.Type == "input_image" {
52+ sawInputImage = true
53+ }
54+ }
55+ }
56+ }
57+ if !sawFnOutput {
58+ t.Fatalf("no function_call_output emitted")
59+ }
60+ if !sawInputImage {
61+ t.Fatalf("mirrored user image was not serialized as input_image")
62+ }
63+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close