diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index 66e1adbb..2743b9da 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -1357,6 +1357,10 @@ type responsesStreamState struct { messageID string role message.Role anyFunctions bool + // imagePartialsSeen records image-generation item IDs that emitted a + // partial_image event, so the output_item.done handler does not emit a + // duplicate final image for them. + imagePartialsSeen map[string]bool } // responsesProcessStreamingUpdate processes a streaming update from the Responses API. @@ -1532,6 +1536,10 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, } case responses.ResponseImageGenCallPartialImageEvent: + if state.imagePartialsSeen == nil { + state.imagePartialsSeen = map[string]bool{} + } + state.imagePartialsSeen[event.ItemID] = true result := imageGenerationResult(event.ItemID, event.PartialImageB64, cmp.Or(event.OutputFormat, "png"), event) result.Outputs[0].Header().AdditionalProperties = map[string]any{ "ItemId": event.ItemID, @@ -1626,7 +1634,13 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, case responses.ResponseFunctionWebSearch: u.Contents = webSearchContents(item) case responses.ResponseOutputItemImageGenerationCall: - // Dedicated image-generation events emit the call and partial results. + // The dedicated partial_image events emit the image incrementally, but + // they are only sent when partial_images > 0. In the default case no + // partial event arrives and the finished image lives on the done item, + // so emit it here unless a partial was already seen for this item. + if !state.imagePartialsSeen[item.ID] { + u.Contents = imageGenerationContents(item) + } case responses.ResponseReasoningItem: // Carry the completed reasoning item's encrypted content so it can be // replayed on the next turn when store=false (reasoning delta events only diff --git a/provider/openaiprovider/responses_test.go b/provider/openaiprovider/responses_test.go index 7b877067..2791fa4e 100644 --- a/provider/openaiprovider/responses_test.go +++ b/provider/openaiprovider/responses_test.go @@ -7779,3 +7779,52 @@ func TestResponsesToolResult_StructSerializedAsJSON(t *testing.T) { t.Errorf("tool result was not JSON-encoded (missing field temp_c):\n%s", captured) } } + +func TestResponsesStreamingImageGenerationCall_NoPartial_EmitsResultOnDone(t *testing.T) { + // The partial_image events are only sent when partial_images > 0. In the + // default case the finished image arrives only on output_item.done, so the + // result must still be surfaced from there. + const imageBase64 = "iVBORw0KGgo=" + const input = ` + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"draw"}]}], + "stream":true + } + ` + const output = `event: response.created +data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + +event: response.image_generation_call.in_progress +data: {"type":"response.image_generation_call.in_progress","sequence_number":1,"output_index":0,"item_id":"ig_123"} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"image_generation_call","id":"ig_123","status":"completed","result":"` + imageBase64 + `"}} + +event: response.completed +data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-mini","output":[]}} + +` + server := newTestResponsesServerStreaming(t, input, output) + defer server.Close() + + a := newTestResponsesClient(server, "gpt-4o-mini") + var result *message.ImageGenerationToolResultContent + for update, err := range a.RunText(t.Context(), "draw", agent.Stream(true)) { + if err != nil { + t.Fatalf("error = %v", err) + } + for _, content := range update.Contents { + if r, ok := content.(*message.ImageGenerationToolResultContent); ok { + result = r + } + } + } + if result == nil || result.CallID != "ig_123" || len(result.Outputs) != 1 { + t.Fatalf("result = %#v, want one output for CallID ig_123", result) + } + image, ok := result.Outputs[0].(*message.DataContent) + if !ok || image.Data != imageBase64 { + t.Fatalf("output = %#v, want DataContent with the image", result.Outputs[0]) + } +}