detached 8d3b7ff

fix(openai): pass image tool results to vision models

Mirror image-bearing tool output into an OpenAI-only user message so GPT vision models receive image bytes, and hide that synthetic message from the TUI transcript.
commit
8d3b7ff155257dee329bb0e76e75c9068ed0c497
author
patriceckhart · · 2026-04-21 19:45 UTC
parent
2cf21d7
signature
unsigned
refs
tag: v0.0.89
3 files · +107 -15
M internal/agent/modes/interactive.go +28 -1
@@ -560,7 +560,7 @@ func (i *Interactive) redraw() {
560560
561561 cols, _ := i.cfg.Terminal.Size()
562562 if i.agent != nil {
563- i.view.Messages = i.agent.Messages()
563+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
564564 } else {
565565 i.view.Messages = nil
566566 }
@@ -896,6 +896,33 @@ func alignSliceStartToImageBlock(chat []string, start, end int) int {
896896 return start
897897 }
898898
899+const hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
900+
901+func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
902+ if len(msgs) == 0 {
903+ return nil
904+ }
905+ out := make([]provider.Message, 0, len(msgs))
906+ for _, m := range msgs {
907+ if isHiddenTranscriptMessage(m) {
908+ continue
909+ }
910+ out = append(out, m)
911+ }
912+ return out
913+}
914+
915+func isHiddenTranscriptMessage(m provider.Message) bool {
916+ if m.Role != provider.RoleUser || len(m.Content) == 0 {
917+ return false
918+ }
919+ tb, ok := m.Content[0].(provider.TextBlock)
920+ if !ok {
921+ return false
922+ }
923+ return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
924+}
925+
899926 func clipBottomClippedImages(lines []string) []string {
900927 if len(lines) == 0 {
901928 return lines
@@ -560,7 +560,7 @@ func (i *Interactive) redraw() {
560 560
561 cols, _ := i.cfg.Terminal.Size()561 cols, _ := i.cfg.Terminal.Size()
562 if i.agent != nil {562 if i.agent != nil {
563- i.view.Messages = i.agent.Messages()563+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
564 } else {564 } else {
565 i.view.Messages = nil565 i.view.Messages = nil
566 }566 }
@@ -896,6 +896,33 @@ func alignSliceStartToImageBlock(chat []string, start, end int) int {
896 return start896 return start
897 }897 }
898 898
899+const hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
900+
901+func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
902+ if len(msgs) == 0 {
903+ return nil
904+ }
905+ out := make([]provider.Message, 0, len(msgs))
906+ for _, m := range msgs {
907+ if isHiddenTranscriptMessage(m) {
908+ continue
909+ }
910+ out = append(out, m)
911+ }
912+ return out
913+}
914+
915+func isHiddenTranscriptMessage(m provider.Message) bool {
916+ if m.Role != provider.RoleUser || len(m.Content) == 0 {
917+ return false
918+ }
919+ tb, ok := m.Content[0].(provider.TextBlock)
920+ if !ok {
921+ return false
922+ }
923+ return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
924+}
925+
899 func clipBottomClippedImages(lines []string) []string {926 func clipBottomClippedImages(lines []string) []string {
900 if len(lines) == 0 {927 if len(lines) == 0 {
901 return lines928 return lines
M internal/core/agent.go +41 -0
@@ -181,6 +181,18 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
181181 toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink)
182182 a.mu.Lock()
183183 a.messages = append(a.messages, toolMsg)
184+ // OpenAI's chat-completions tool message shape is text-centric.
185+ // Vision models reliably consume images when they arrive as user
186+ // content, so when a tool result contains images we mirror them
187+ // into a synthetic user message immediately after the tool result.
188+ // This keeps the transcript self-contained for providers that can
189+ // see image blocks in tool messages while making OpenAI vision
190+ // models actually receive the image bytes.
191+ if a.Client != nil && a.Client.Name() == "openai" {
192+ if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 {
193+ a.messages = append(a.messages, mirror)
194+ }
195+ }
184196 a.mu.Unlock()
185197 // If context was cancelled during tool execution, bail out.
186198 if err := ctx.Err(); err != nil {
@@ -388,6 +400,35 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
388400 // extractText concatenates all TextBlock content in a message. Used
389401 // by BeforeAssistantMessage so guards see a single string instead of
390402 // having to walk provider.Content themselves.
403+func mirrorToolImagesAsUser(msg provider.Message) provider.Message {
404+ var content []provider.Content
405+ for _, c := range msg.Content {
406+ tr, ok := c.(provider.ToolResultBlock)
407+ if !ok {
408+ continue
409+ }
410+ for _, inner := range tr.Content {
411+ switch v := inner.(type) {
412+ case provider.TextBlock:
413+ // Keep short textual context so the model understands why
414+ // the images appeared, but don't duplicate giant read
415+ // outputs verbatim.
416+ if len(v.Text) > 0 && len(v.Text) <= 500 {
417+ content = append(content, v)
418+ }
419+ case provider.ImageBlock:
420+ content = append(content, v)
421+ }
422+ }
423+ }
424+ if len(content) == 0 {
425+ return provider.Message{}
426+ }
427+ prefix := provider.TextBlock{Text: "Tool output included the following image content:"}
428+ content = append([]provider.Content{prefix}, content...)
429+ return provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}
430+}
431+
391432 func extractText(msg provider.Message) string {
392433 var out string
393434 for _, c := range msg.Content {
@@ -181,6 +181,18 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
181 toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink)181 toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink)
182 a.mu.Lock()182 a.mu.Lock()
183 a.messages = append(a.messages, toolMsg)183 a.messages = append(a.messages, toolMsg)
184+ // OpenAI's chat-completions tool message shape is text-centric.
185+ // Vision models reliably consume images when they arrive as user
186+ // content, so when a tool result contains images we mirror them
187+ // into a synthetic user message immediately after the tool result.
188+ // This keeps the transcript self-contained for providers that can
189+ // see image blocks in tool messages while making OpenAI vision
190+ // models actually receive the image bytes.
191+ if a.Client != nil && a.Client.Name() == "openai" {
192+ if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 {
193+ a.messages = append(a.messages, mirror)
194+ }
195+ }
184 a.mu.Unlock()196 a.mu.Unlock()
185 // If context was cancelled during tool execution, bail out.197 // If context was cancelled during tool execution, bail out.
186 if err := ctx.Err(); err != nil {198 if err := ctx.Err(); err != nil {
@@ -388,6 +400,35 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
388 // extractText concatenates all TextBlock content in a message. Used400 // extractText concatenates all TextBlock content in a message. Used
389 // by BeforeAssistantMessage so guards see a single string instead of401 // by BeforeAssistantMessage so guards see a single string instead of
390 // having to walk provider.Content themselves.402 // having to walk provider.Content themselves.
403+func mirrorToolImagesAsUser(msg provider.Message) provider.Message {
404+ var content []provider.Content
405+ for _, c := range msg.Content {
406+ tr, ok := c.(provider.ToolResultBlock)
407+ if !ok {
408+ continue
409+ }
410+ for _, inner := range tr.Content {
411+ switch v := inner.(type) {
412+ case provider.TextBlock:
413+ // Keep short textual context so the model understands why
414+ // the images appeared, but don't duplicate giant read
415+ // outputs verbatim.
416+ if len(v.Text) > 0 && len(v.Text) <= 500 {
417+ content = append(content, v)
418+ }
419+ case provider.ImageBlock:
420+ content = append(content, v)
421+ }
422+ }
423+ }
424+ if len(content) == 0 {
425+ return provider.Message{}
426+ }
427+ prefix := provider.TextBlock{Text: "Tool output included the following image content:"}
428+ content = append([]provider.Content{prefix}, content...)
429+ return provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}
430+}
431+
391 func extractText(msg provider.Message) string {432 func extractText(msg provider.Message) string {
392 var out string433 var out string
393 for _, c := range msg.Content {434 for _, c := range msg.Content {
M internal/provider/openai.go +38 -14
@@ -174,25 +174,16 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
174174 }
175175 out.Messages = append(out.Messages, am)
176176 case RoleTool:
177- // Each ToolResultBlock becomes its own tool message.
177+ // Each ToolResultBlock becomes its own tool message. Preserve
178+ // image blocks for vision-capable OpenAI models instead of
179+ // flattening the tool output to plain text.
178180 for _, b := range msg.Content {
179181 if tr, ok := b.(ToolResultBlock); ok {
180- var text strings.Builder
181- for _, inner := range tr.Content {
182- if tb, ok := inner.(TextBlock); ok {
183- if text.Len() > 0 {
184- text.WriteString("\n")
185- }
186- text.WriteString(tb.Text)
187- }
188- }
189- if tr.IsError && text.Len() > 0 {
190- text.WriteString(" [error]")
191- }
182+ content := buildOAIToolContent(tr.Content, tr.IsError)
192183 out.Messages = append(out.Messages, oaiMessage{
193184 Role: "tool",
194185 ToolCallID: tr.CallID,
195- Content: text.String(),
186+ Content: content,
196187 })
197188 }
198189 }
@@ -234,6 +225,36 @@ func buildOAIUserContent(blocks []Content) interface{} {
234225 }
235226 return sb.String()
236227 }
228+ return buildOAIContentBlocks(blocks, false)
229+}
230+
231+func buildOAIToolContent(blocks []Content, isError bool) interface{} {
232+ hasImage := false
233+ for _, b := range blocks {
234+ if _, ok := b.(ImageBlock); ok {
235+ hasImage = true
236+ break
237+ }
238+ }
239+ if !hasImage {
240+ var sb strings.Builder
241+ for _, b := range blocks {
242+ if tb, ok := b.(TextBlock); ok {
243+ if sb.Len() > 0 {
244+ sb.WriteString("\n")
245+ }
246+ sb.WriteString(tb.Text)
247+ }
248+ }
249+ if isError && sb.Len() > 0 {
250+ sb.WriteString(" [error]")
251+ }
252+ return sb.String()
253+ }
254+ return buildOAIContentBlocks(blocks, isError)
255+}
256+
257+func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} {
237258 var arr []interface{}
238259 for _, b := range blocks {
239260 switch v := b.(type) {
@@ -246,6 +267,9 @@ func buildOAIUserContent(blocks []Content) interface{} {
246267 arr = append(arr, img)
247268 }
248269 }
270+ if isError {
271+ arr = append(arr, oaiContentText{Type: "text", Text: "[error]"})
272+ }
249273 return arr
250274 }
251275
@@ -174,25 +174,16 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
174 }174 }
175 out.Messages = append(out.Messages, am)175 out.Messages = append(out.Messages, am)
176 case RoleTool:176 case RoleTool:
177- // Each ToolResultBlock becomes its own tool message.177+ // Each ToolResultBlock becomes its own tool message. Preserve
178+ // image blocks for vision-capable OpenAI models instead of
179+ // flattening the tool output to plain text.
178 for _, b := range msg.Content {180 for _, b := range msg.Content {
179 if tr, ok := b.(ToolResultBlock); ok {181 if tr, ok := b.(ToolResultBlock); ok {
180- var text strings.Builder182+ content := buildOAIToolContent(tr.Content, tr.IsError)
181- for _, inner := range tr.Content {
182- if tb, ok := inner.(TextBlock); ok {
183- if text.Len() > 0 {
184- text.WriteString("\n")
185- }
186- text.WriteString(tb.Text)
187- }
188- }
189- if tr.IsError && text.Len() > 0 {
190- text.WriteString(" [error]")
191- }
192 out.Messages = append(out.Messages, oaiMessage{183 out.Messages = append(out.Messages, oaiMessage{
193 Role: "tool",184 Role: "tool",
194 ToolCallID: tr.CallID,185 ToolCallID: tr.CallID,
195- Content: text.String(),186+ Content: content,
196 })187 })
197 }188 }
198 }189 }
@@ -234,6 +225,36 @@ func buildOAIUserContent(blocks []Content) interface{} {
234 }225 }
235 return sb.String()226 return sb.String()
236 }227 }
228+ return buildOAIContentBlocks(blocks, false)
229+}
230+
231+func buildOAIToolContent(blocks []Content, isError bool) interface{} {
232+ hasImage := false
233+ for _, b := range blocks {
234+ if _, ok := b.(ImageBlock); ok {
235+ hasImage = true
236+ break
237+ }
238+ }
239+ if !hasImage {
240+ var sb strings.Builder
241+ for _, b := range blocks {
242+ if tb, ok := b.(TextBlock); ok {
243+ if sb.Len() > 0 {
244+ sb.WriteString("\n")
245+ }
246+ sb.WriteString(tb.Text)
247+ }
248+ }
249+ if isError && sb.Len() > 0 {
250+ sb.WriteString(" [error]")
251+ }
252+ return sb.String()
253+ }
254+ return buildOAIContentBlocks(blocks, isError)
255+}
256+
257+func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} {
237 var arr []interface{}258 var arr []interface{}
238 for _, b := range blocks {259 for _, b := range blocks {
239 switch v := b.(type) {260 switch v := b.(type) {
@@ -246,6 +267,9 @@ func buildOAIUserContent(blocks []Content) interface{} {
246 arr = append(arr, img)267 arr = append(arr, img)
247 }268 }
248 }269 }
270+ if isError {
271+ arr = append(arr, oaiContentText{Type: "text", Text: "[error]"})
272+ }
249 return arr273 return arr
250 }274 }
251 275

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