Compare commits

...

2 Commits

Author SHA1 Message Date
7875bc26fb test: add tests for image analysis in file_tools
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 36s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 37s
Tests / e2e (pull_request) Successful in 2m45s
Tests / test (pull_request) Failing after 42m57s
Refs #800
2026-04-15 16:29:10 +00:00
91a6a52fc9 feat: wire Gemma 4 vision into file_tools for image analysis (#800)
- Detect image files (PNG, JPG, WebP, GIF, etc.)
- Route through vision_analyze_tool automatically
- Return structured analysis with text extraction
- Updated schema to document image support

Closes #800
2026-04-15 16:27:05 +00:00
2 changed files with 248 additions and 1 deletions

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""
Tests for image analysis integration in file_tools.
"""
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Add tools to path
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools"))
from file_tools import read_file_tool, IMAGE_EXTENSIONS
class TestImageExtensions:
"""Test image extension detection."""
def test_common_extensions(self):
"""Test that common image extensions are included."""
common = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
for ext in common:
assert ext in IMAGE_EXTENSIONS
def test_tiff_extensions(self):
"""Test TIFF extensions are included."""
assert ".tiff" in IMAGE_EXTENSIONS
assert ".tif" in IMAGE_EXTENSIONS
class TestReadFileImageHandling:
"""Test image file handling in read_file_tool."""
@patch('file_tools.vision_analyze_tool', new_callable=AsyncMock)
def test_png_file_analyzed(self, mock_vision, tmp_path):
"""Test that PNG files are analyzed with vision."""
# Create a dummy PNG file
png_file = tmp_path / "test.png"
png_file.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
# Mock vision analysis
mock_vision.return_value = "A test image showing a red circle on white background."
# Read the file
result = read_file_tool(str(png_file))
data = json.loads(result)
# Check that vision was called
assert "type" in data
assert data["type"] == "image_analysis"
assert "analysis" in data
assert "red circle" in data["analysis"]
@patch('file_tools.vision_analyze_tool', new_callable=AsyncMock)
def test_jpg_file_analyzed(self, mock_vision, tmp_path):
"""Test that JPG files are analyzed with vision."""
jpg_file = tmp_path / "photo.jpg"
jpg_file.write_bytes(b"\xff\xd8\xff" + b"\x00" * 100)
mock_vision.return_value = "A photograph of a sunset."
result = read_file_tool(str(jpg_file))
data = json.loads(result)
assert data["type"] == "image_analysis"
assert "sunset" in data["analysis"]
@patch('file_tools.vision_analyze_tool', new_callable=AsyncMock)
def test_webp_file_analyzed(self, mock_vision, tmp_path):
"""Test that WebP files are analyzed with vision."""
webp_file = tmp_path / "image.webp"
webp_file.write_bytes(b"RIFF" + b"\x00" * 100)
mock_vision.return_value = "A WebP image of a cat."
result = read_file_tool(str(webp_file))
data = json.loads(result)
assert data["type"] == "image_analysis"
assert "cat" in data["analysis"]
def test_non_image_binary_still_blocked(self, tmp_path):
"""Test that non-image binary files are still blocked."""
zip_file = tmp_path / "archive.zip"
zip_file.write_bytes(b"PK" + b"\x00" * 100)
result = read_file_tool(str(zip_file))
data = json.loads(result)
assert "error" in data
assert "Cannot read binary file" in data["error"]
def test_text_file_unchanged(self, tmp_path):
"""Test that text files are still read normally."""
txt_file = tmp_path / "readme.txt"
txt_file.write_text("Hello, world!\nThis is a test file.\n")
result = read_file_tool(str(txt_file))
data = json.loads(result)
# Should have content, not image analysis
assert "content" in data or "lines" in data
assert "type" not in data or data.get("type") != "image_analysis"
@patch('file_tools.vision_analyze_tool', new_callable=AsyncMock)
def test_image_analysis_error_handling(self, mock_vision, tmp_path):
"""Test error handling when vision analysis fails."""
png_file = tmp_path / "broken.png"
png_file.write_bytes(b"\x89PNG" + b"\x00" * 100)
# Mock vision to raise an error
mock_vision.side_effect = Exception("Vision API error")
result = read_file_tool(str(png_file))
data = json.loads(result)
assert "error" in data
assert "Image analysis failed" in data["error"]
@patch('file_tools.vision_analyze_tool', new_callable=AsyncMock)
def test_image_result_structure(self, mock_vision, tmp_path):
"""Test that image analysis result has correct structure."""
png_file = tmp_path / "chart.png"
png_file.write_bytes(b"\x89PNG" + b"\x00" * 100)
mock_vision.return_value = "A bar chart showing sales data."
result = read_file_tool(str(png_file))
data = json.loads(result)
# Check required fields
assert "type" in data
assert data["type"] == "image_analysis"
assert "file_path" in data
assert "extension" in data
assert data["extension"] == ".png"
assert "analysis" in data
assert "note" in data
class TestImageAnalysisIntegration:
"""Integration tests for image analysis."""
@patch('file_tools.vision_analyze_tool', new_callable=AsyncMock)
def test_multiple_image_types(self, mock_vision, tmp_path):
"""Test analyzing multiple image types."""
mock_vision.return_value = "Test image analysis result."
# Test PNG
png = tmp_path / "test.png"
png.write_bytes(b"\x89PNG" + b"\x00" * 100)
result = read_file_tool(str(png))
data = json.loads(result)
assert data["type"] == "image_analysis"
# Test JPG
jpg = tmp_path / "test.jpg"
jpg.write_bytes(b"\xff\xd8\xff" + b"\x00" * 100)
result = read_file_tool(str(jpg))
data = json.loads(result)
assert data["type"] == "image_analysis"
# Test GIF
gif = tmp_path / "test.gif"
gif.write_bytes(b"GIF89a" + b"\x00" * 100)
result = read_file_tool(str(gif))
data = json.loads(result)
assert data["type"] == "image_analysis"
def test_nonexistent_image_file(self):
"""Test handling of nonexistent image files."""
result = read_file_tool("/nonexistent/image.png")
data = json.loads(result)
# Should get an error (file doesn't exist)
assert "error" in data
@patch('file_tools.vision_analyze_tool', new_callable=AsyncMock)
def test_image_with_spaces_in_name(self, mock_vision, tmp_path):
"""Test image files with spaces in the name."""
mock_vision.return_value = "A test image."
png = tmp_path / "my image file.png"
png.write_bytes(b"\x89PNG" + b"\x00" * 100)
result = read_file_tool(str(png))
data = json.loads(result)
assert data["type"] == "image_analysis"
assert "my image file.png" in data["file_path"]
class TestVisionToolImport:
"""Test that vision tool is properly imported."""
def test_vision_tool_import(self):
"""Test that vision_analyze_tool can be imported."""
try:
from tools.vision_tools import vision_analyze_tool
assert callable(vision_analyze_tool)
except ImportError:
pytest.skip("vision_tools not available")
def test_image_extensions_constant(self):
"""Test IMAGE_EXTENSIONS constant is accessible."""
from file_tools import IMAGE_EXTENSIONS
assert isinstance(IMAGE_EXTENSIONS, set)
assert len(IMAGE_EXTENSIONS) > 0

View File

@@ -296,9 +296,43 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
_resolved = Path(path).expanduser().resolve()
# ── Binary file guard ─────────────────────────────────────────
# Block binary files by extension (no I/O).
# Block binary files by extension, but handle images with vision.
if has_binary_extension(str(_resolved)):
_ext = _resolved.suffix.lower()
# Route image files through vision analysis
if _ext in IMAGE_EXTENSIONS:
try:
import asyncio
from tools.vision_tools import vision_analyze_tool
# Analyze the image
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
vision_analyze_tool(
image_url=str(_resolved),
user_prompt="Describe this image in detail. Extract any text, describe the content, and note any important visual elements."
)
)
return json.dumps({
"type": "image_analysis",
"file_path": str(_resolved),
"extension": _ext,
"analysis": result,
"note": "This file was analyzed using vision AI. Use vision_analyze with a specific prompt for more targeted analysis."
})
finally:
loop.close()
except Exception as e:
return json.dumps({
"error": f"Image analysis failed for '{path}': {str(e)}",
"file_path": str(_resolved),
"extension": _ext,
})
# Non-image binary files
return json.dumps({
"error": (
f"Cannot read binary file '{path}' ({_ext}). "