shelley: browser: support file downloads and large output handling

Philip Zeyliger and Shelley created

Prompt: Handle https://github.com/boldsoftware/shelley/issues/7 - support browser downloads and write large outputs to files.

---

Add support for browser-initiated file downloads and improve handling of
large tool outputs.

**Downloads:**
- Browser now saves downloaded files to /tmp/shelley-downloads/ with
  randomized filenames (e.g., filename_abc12345.ext)
- Downloads are reported in navigate/eval tool output with filename,
  source URL, and save path
- Navigation to download URLs (like direct PDF links) now reports
  success with download info instead of ERR_ABORTED error

**Large output handling:**
- Console logs (browser_recent_console_logs) exceeding 1KB are written
  to /tmp/shelley-console-logs/ and the tool returns the file path
- JavaScript results (browser_eval) exceeding 1KB are similarly written
  to files instead of returned inline

Example output for downloads:
  Navigation triggered download(s):
    - report.pdf (from https://example.com/report.pdf) saved to: /tmp/shelley-downloads/report_a1b2c3d4.pdf

Example output for large JS results:
  JavaScript result (5000 bytes) written to: /tmp/shelley-console-logs/js_result_x1y2z3.json
  Use `cat ...` to view the full content.

Fixes https://github.com/boldsoftware/shelley/issues/7
Inspired by https://github.com/boldsoftware/shelley/pull/8

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

claudetool/browse/browse.go      | 207 ++++++++++++++
claudetool/browse/browse_test.go | 480 ++++++++++++++++++++++++++++++++++
2 files changed, 680 insertions(+), 7 deletions(-)

Detailed changes

claudetool/browse/browse.go 🔗

@@ -15,6 +15,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/chromedp/cdproto/browser"
 	"github.com/chromedp/cdproto/runtime"
 	"github.com/chromedp/chromedp"
 	"github.com/google/uuid"
@@ -25,9 +26,28 @@ import (
 // ScreenshotDir is the directory where screenshots are stored
 const ScreenshotDir = "/tmp/shelley-screenshots"
 
+// DownloadDir is the directory where downloads are stored
+const DownloadDir = "/tmp/shelley-downloads"
+
+// ConsoleLogsDir is the directory where large console logs are stored
+const ConsoleLogsDir = "/tmp/shelley-console-logs"
+
+// ConsoleLogSizeThreshold is the size in bytes above which console logs are written to a file
+const ConsoleLogSizeThreshold = 1024
+
 // DefaultIdleTimeout is how long to wait before shutting down an idle browser
 const DefaultIdleTimeout = 30 * time.Minute
 
+// DownloadInfo tracks information about a completed download
+type DownloadInfo struct {
+	GUID              string
+	URL               string
+	SuggestedFilename string
+	FinalPath         string
+	Completed         bool
+	Error             string
+}
+
 // BrowseTools contains all browser tools and manages a shared browser instance
 type BrowseTools struct {
 	ctx              context.Context
@@ -48,6 +68,10 @@ type BrowseTools struct {
 	idleTimer   *time.Timer
 	// Max image dimension for resizing (0 means use default)
 	maxImageDimension int
+	// Download tracking
+	downloads      map[string]*DownloadInfo // keyed by GUID
+	downloadsMutex sync.Mutex
+	downloadCond   *sync.Cond
 }
 
 // NewBrowseTools creates a new set of browser automation tools.
@@ -57,18 +81,23 @@ func NewBrowseTools(ctx context.Context, idleTimeout time.Duration, maxImageDime
 	if idleTimeout <= 0 {
 		idleTimeout = DefaultIdleTimeout
 	}
-	if err := os.MkdirAll(ScreenshotDir, 0o755); err != nil {
-		log.Printf("Failed to create screenshot directory: %v", err)
+	for _, dir := range []string{ScreenshotDir, DownloadDir, ConsoleLogsDir} {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			log.Printf("Failed to create directory %s: %v", dir, err)
+		}
 	}
 
-	return &BrowseTools{
+	bt := &BrowseTools{
 		ctx:               ctx,
 		screenshots:       make(map[string]time.Time),
 		consoleLogs:       make([]*runtime.EventConsoleAPICalled, 0),
 		maxConsoleLogs:    100,
 		maxImageDimension: maxImageDimension,
 		idleTimeout:       idleTimeout,
+		downloads:         make(map[string]*DownloadInfo),
 	}
+	bt.downloadCond = sync.NewCond(&bt.downloadsMutex)
+	return bt
 }
 
 // GetBrowserContext returns the browser context, initializing if needed and resetting the idle timer.
@@ -96,10 +125,15 @@ func (b *BrowseTools) GetBrowserContext() (context.Context, error) {
 		chromedp.WithBrowserOption(chromedp.WithDialTimeout(60*time.Second)),
 	)
 
-	// Set up console log listener
+	// Set up event listeners for console logs and downloads
 	chromedp.ListenTarget(browserCtx, func(ev any) {
-		if e, ok := ev.(*runtime.EventConsoleAPICalled); ok {
+		switch e := ev.(type) {
+		case *runtime.EventConsoleAPICalled:
 			b.captureConsoleLog(e)
+		case *browser.EventDownloadWillBegin:
+			b.handleDownloadWillBegin(e)
+		case *browser.EventDownloadProgress:
+			b.handleDownloadProgress(e)
 		}
 	})
 
@@ -116,6 +150,17 @@ func (b *BrowseTools) GetBrowserContext() (context.Context, error) {
 		return nil, fmt.Errorf("failed to set default viewport: %w", err)
 	}
 
+	// Configure download behavior to allow downloads and emit events
+	if err := chromedp.Run(browserCtx,
+		browser.SetDownloadBehavior(browser.SetDownloadBehaviorBehaviorAllowAndName).
+			WithDownloadPath(DownloadDir).
+			WithEventsEnabled(true),
+	); err != nil {
+		browserCancel()
+		allocCancel()
+		return nil, fmt.Errorf("failed to configure download behavior: %w", err)
+	}
+
 	b.allocCtx = allocCtx
 	b.allocCancel = allocCancel
 	b.browserCtx = browserCtx
@@ -238,10 +283,29 @@ func (b *BrowseTools) navigateRun(ctx context.Context, m json.RawMessage) llm.To
 		chromedp.WaitReady("body"),
 	)
 	if err != nil {
+		// Navigation to download URLs fails with ERR_ABORTED, but the download may have succeeded.
+		// Wait briefly for download events to be processed, then check if we got any downloads.
+		if strings.Contains(err.Error(), "net::ERR_ABORTED") {
+			time.Sleep(500 * time.Millisecond)
+			downloads := b.GetRecentDownloads()
+			if len(downloads) > 0 {
+				// Download succeeded - report it instead of error
+				var sb strings.Builder
+				sb.WriteString("Navigation triggered download(s):")
+				for _, d := range downloads {
+					if d.Error != "" {
+						sb.WriteString(fmt.Sprintf("\n  - %s (from %s): ERROR: %s", d.SuggestedFilename, d.URL, d.Error))
+					} else {
+						sb.WriteString(fmt.Sprintf("\n  - %s (from %s) saved to: %s", d.SuggestedFilename, d.URL, d.FinalPath))
+					}
+				}
+				return llm.ToolOut{LLMContent: llm.TextContent(sb.String())}
+			}
+		}
 		return llm.ErrorToolOut(err)
 	}
 
-	return llm.ToolOut{LLMContent: llm.TextContent("done")}
+	return b.toolOutWithDownloads("done")
 }
 
 // ResizeTool definition
@@ -382,7 +446,19 @@ func (b *BrowseTools) evalRun(ctx context.Context, m json.RawMessage) llm.ToolOu
 		return llm.ErrorfToolOut("failed to marshal response: %w", err)
 	}
 
-	return llm.ToolOut{LLMContent: llm.TextContent("<javascript_result>" + string(response) + "</javascript_result>")}
+	// If output exceeds threshold, write to file
+	if len(response) > ConsoleLogSizeThreshold {
+		filename := fmt.Sprintf("js_result_%s.json", uuid.New().String()[:8])
+		filePath := filepath.Join(ConsoleLogsDir, filename)
+		if err := os.WriteFile(filePath, response, 0o644); err != nil {
+			return llm.ErrorfToolOut("failed to write JS result to file: %w", err)
+		}
+		return b.toolOutWithDownloads(fmt.Sprintf(
+			"JavaScript result (%d bytes) written to: %s\nUse `cat %s` to view the full content.",
+			len(response), filePath, filePath))
+	}
+
+	return b.toolOutWithDownloads("<javascript_result>" + string(response) + "</javascript_result>")
 }
 
 // ScreenshotTool definition
@@ -648,6 +724,111 @@ func (b *BrowseTools) captureConsoleLog(e *runtime.EventConsoleAPICalled) {
 	}
 }
 
+// handleDownloadWillBegin handles the browser download start event
+func (b *BrowseTools) handleDownloadWillBegin(e *browser.EventDownloadWillBegin) {
+	b.downloadsMutex.Lock()
+	defer b.downloadsMutex.Unlock()
+
+	b.downloads[e.GUID] = &DownloadInfo{
+		GUID:              e.GUID,
+		URL:               e.URL,
+		SuggestedFilename: e.SuggestedFilename,
+	}
+}
+
+// handleDownloadProgress handles the browser download progress event
+func (b *BrowseTools) handleDownloadProgress(e *browser.EventDownloadProgress) {
+	b.downloadsMutex.Lock()
+	defer b.downloadsMutex.Unlock()
+
+	info, ok := b.downloads[e.GUID]
+	if !ok {
+		// Download started before we started tracking, create entry
+		info = &DownloadInfo{GUID: e.GUID}
+		b.downloads[e.GUID] = info
+	}
+
+	switch e.State {
+	case browser.DownloadProgressStateCompleted:
+		info.Completed = true
+		// The file is downloaded with GUID as filename, rename to suggested filename with random suffix
+		guidPath := filepath.Join(DownloadDir, e.GUID)
+		finalName := b.generateDownloadFilename(info.SuggestedFilename)
+		finalPath := filepath.Join(DownloadDir, finalName)
+		// Retry rename a few times as file might still be being written
+		var renamed bool
+		for i := 0; i < 10; i++ {
+			if err := os.Rename(guidPath, finalPath); err == nil {
+				info.FinalPath = finalPath
+				renamed = true
+				break
+			}
+			time.Sleep(50 * time.Millisecond)
+		}
+		if !renamed {
+			// File might have different path or couldn't be renamed
+			if e.FilePath != "" {
+				info.FinalPath = e.FilePath
+			} else {
+				info.FinalPath = guidPath
+			}
+		}
+		b.downloadCond.Broadcast()
+	case browser.DownloadProgressStateCanceled:
+		info.Completed = true
+		info.Error = "download canceled"
+		b.downloadCond.Broadcast()
+	}
+}
+
+// generateDownloadFilename creates a filename with randomness
+func (b *BrowseTools) generateDownloadFilename(suggested string) string {
+	if suggested == "" {
+		suggested = "download"
+	}
+	// Extract extension if present
+	ext := filepath.Ext(suggested)
+	base := strings.TrimSuffix(suggested, ext)
+	// Add random suffix
+	randomSuffix := uuid.New().String()[:8]
+	return fmt.Sprintf("%s_%s%s", base, randomSuffix, ext)
+}
+
+// GetRecentDownloads returns download info for recently completed downloads and clears the list
+func (b *BrowseTools) GetRecentDownloads() []*DownloadInfo {
+	b.downloadsMutex.Lock()
+	defer b.downloadsMutex.Unlock()
+
+	var completed []*DownloadInfo
+	for guid, info := range b.downloads {
+		if info.Completed {
+			completed = append(completed, info)
+			delete(b.downloads, guid)
+		}
+	}
+	return completed
+}
+
+// toolOutWithDownloads creates a tool output that includes any completed downloads
+func (b *BrowseTools) toolOutWithDownloads(message string) llm.ToolOut {
+	downloads := b.GetRecentDownloads()
+	if len(downloads) == 0 {
+		return llm.ToolOut{LLMContent: llm.TextContent(message)}
+	}
+
+	var sb strings.Builder
+	sb.WriteString(message)
+	sb.WriteString("\n\nDownloads completed:")
+	for _, d := range downloads {
+		if d.Error != "" {
+			sb.WriteString(fmt.Sprintf("\n  - %s (from %s): ERROR: %s", d.SuggestedFilename, d.URL, d.Error))
+		} else {
+			sb.WriteString(fmt.Sprintf("\n  - %s (from %s) saved to: %s", d.SuggestedFilename, d.URL, d.FinalPath))
+		}
+	}
+	return llm.ToolOut{LLMContent: llm.TextContent(sb.String())}
+}
+
 // RecentConsoleLogsTool definition
 type recentConsoleLogsInput struct {
 	Limit int `json:"limit,omitempty"`
@@ -705,6 +886,18 @@ func (b *BrowseTools) recentConsoleLogsRun(ctx context.Context, m json.RawMessag
 		return llm.ErrorfToolOut("failed to serialize logs: %w", err)
 	}
 
+	// If output exceeds threshold, write to file
+	if len(logData) > ConsoleLogSizeThreshold {
+		filename := fmt.Sprintf("console_logs_%s.json", uuid.New().String()[:8])
+		filePath := filepath.Join(ConsoleLogsDir, filename)
+		if err := os.WriteFile(filePath, logData, 0o644); err != nil {
+			return llm.ErrorfToolOut("failed to write console logs to file: %w", err)
+		}
+		return llm.ToolOut{LLMContent: llm.TextContent(fmt.Sprintf(
+			"Retrieved %d console log entries (%d bytes).\nOutput written to: %s\nUse `cat %s` to view the full content.",
+			len(logs), len(logData), filePath, filePath))}
+	}
+
 	// Format the logs
 	var sb strings.Builder
 	sb.WriteString(fmt.Sprintf("Retrieved %d console log entries:\n\n", len(logs)))

claudetool/browse/browse_test.go 🔗

@@ -9,6 +9,8 @@ import (
 	"image"
 	"image/color"
 	"image/png"
+	"net"
+	"net/http"
 	"os"
 	"path/filepath"
 	"slices"
@@ -16,7 +18,10 @@ import (
 	"testing"
 	"time"
 
+	"github.com/chromedp/cdproto/browser"
+	"github.com/chromedp/cdproto/runtime"
 	"github.com/chromedp/chromedp"
+	"github.com/go-json-experiment/json/jsontext"
 	"shelley.exe.dev/llm"
 )
 
@@ -648,3 +653,478 @@ func TestSaveScreenshotErrorPath(t *testing.T) {
 	filePath := GetScreenshotPath(id)
 	os.Remove(filePath)
 }
+
+// TestConsoleLogsWriteToFile tests that large console logs are written to file
+func TestConsoleLogsWriteToFile(t *testing.T) {
+	ctx := context.Background()
+	tools := NewBrowseTools(ctx, 0, 0)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Manually add many console logs to exceed threshold
+	tools.consoleLogsMutex.Lock()
+	for i := 0; i < 50; i++ {
+		tools.consoleLogs = append(tools.consoleLogs, &runtime.EventConsoleAPICalled{
+			Type: runtime.APITypeLog,
+			Args: []*runtime.RemoteObject{
+				{Type: runtime.TypeString, Value: jsontext.Value(`"This is a long log message that will help exceed the 1KB threshold when repeated many times"`)},
+			},
+		})
+	}
+	tools.consoleLogsMutex.Unlock()
+
+	// Mock browser context to avoid actual browser initialization
+	tools.mux.Lock()
+	tools.browserCtx = ctx
+	tools.mux.Unlock()
+
+	// Get console logs - should be written to file
+	input := []byte(`{}`)
+	toolOut := tools.recentConsoleLogsRun(ctx, input)
+	if toolOut.Error != nil {
+		t.Fatalf("Unexpected error: %v", toolOut.Error)
+	}
+
+	resultText := toolOut.LLMContent[0].Text
+	if !strings.Contains(resultText, "Output written to:") {
+		t.Errorf("Expected output to be written to file, got: %s", resultText)
+	}
+	if !strings.Contains(resultText, ConsoleLogsDir) {
+		t.Errorf("Expected file path to contain %s, got: %s", ConsoleLogsDir, resultText)
+	}
+
+	// Extract file path and verify file exists
+	parts := strings.Split(resultText, "Output written to: ")
+	if len(parts) < 2 {
+		t.Fatalf("Could not extract file path from: %s", resultText)
+	}
+	filePath := strings.Split(parts[1], "\n")[0]
+	if _, err := os.Stat(filePath); os.IsNotExist(err) {
+		t.Errorf("Expected file to exist at %s", filePath)
+	} else {
+		// Clean up
+		os.Remove(filePath)
+	}
+}
+
+// TestGenerateDownloadFilename tests filename generation with randomness
+func TestGenerateDownloadFilename(t *testing.T) {
+	ctx := context.Background()
+	tools := NewBrowseTools(ctx, 0, 0)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	tests := []struct {
+		suggested string
+		prefix    string
+		ext       string
+	}{
+		{"test.txt", "test_", ".txt"},
+		{"document.pdf", "document_", ".pdf"},
+		{"noextension", "noextension_", ""},
+		{"", "download_", ""},
+		{"file.tar.gz", "file.tar_", ".gz"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.suggested, func(t *testing.T) {
+			result := tools.generateDownloadFilename(tt.suggested)
+			if !strings.HasPrefix(result, tt.prefix) {
+				t.Errorf("Expected prefix %q, got %q", tt.prefix, result)
+			}
+			if !strings.HasSuffix(result, tt.ext) {
+				t.Errorf("Expected suffix %q, got %q", tt.ext, result)
+			}
+			// Verify randomness (8 chars between prefix and extension)
+			withoutPrefix := strings.TrimPrefix(result, tt.prefix)
+			withoutExt := strings.TrimSuffix(withoutPrefix, tt.ext)
+			if len(withoutExt) != 8 {
+				t.Errorf("Expected 8 random chars, got %d in %q", len(withoutExt), result)
+			}
+		})
+	}
+
+	// Verify different calls produce different results
+	result1 := tools.generateDownloadFilename("test.txt")
+	result2 := tools.generateDownloadFilename("test.txt")
+	if result1 == result2 {
+		t.Errorf("Expected different filenames, got same: %s", result1)
+	}
+}
+
+// TestDownloadTracking tests the download event handling
+func TestDownloadTracking(t *testing.T) {
+	ctx := context.Background()
+	tools := NewBrowseTools(ctx, 0, 0)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Simulate download start event
+	tools.handleDownloadWillBegin(&browser.EventDownloadWillBegin{
+		GUID:              "test-guid-123",
+		URL:               "http://example.com/file.txt",
+		SuggestedFilename: "file.txt",
+	})
+
+	// Verify download is tracked
+	tools.downloadsMutex.Lock()
+	info, exists := tools.downloads["test-guid-123"]
+	tools.downloadsMutex.Unlock()
+
+	if !exists {
+		t.Fatal("Expected download to be tracked")
+	}
+	if info.URL != "http://example.com/file.txt" {
+		t.Errorf("Expected URL %q, got %q", "http://example.com/file.txt", info.URL)
+	}
+	if info.Completed {
+		t.Error("Download should not be completed yet")
+	}
+
+	// Simulate download progress - canceled
+	tools.handleDownloadProgress(&browser.EventDownloadProgress{
+		GUID:  "test-guid-123",
+		State: browser.DownloadProgressStateCanceled,
+	})
+
+	// Verify download is marked as completed with error
+	tools.downloadsMutex.Lock()
+	info = tools.downloads["test-guid-123"]
+	tools.downloadsMutex.Unlock()
+
+	if !info.Completed {
+		t.Error("Download should be completed after cancel")
+	}
+	if info.Error != "download canceled" {
+		t.Errorf("Expected error %q, got %q", "download canceled", info.Error)
+	}
+}
+
+// TestToolOutWithDownloads tests the download info appending to tool output
+func TestToolOutWithDownloads(t *testing.T) {
+	ctx := context.Background()
+	tools := NewBrowseTools(ctx, 0, 0)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Test with no downloads
+	out := tools.toolOutWithDownloads("test message")
+	if out.LLMContent[0].Text != "test message" {
+		t.Errorf("Expected %q, got %q", "test message", out.LLMContent[0].Text)
+	}
+
+	// Add a completed download
+	tools.downloadsMutex.Lock()
+	tools.downloads["guid1"] = &DownloadInfo{
+		GUID:              "guid1",
+		URL:               "http://example.com/files/test.txt",
+		SuggestedFilename: "test.txt",
+		FinalPath:         "/tmp/test_abc123.txt",
+		Completed:         true,
+	}
+	tools.downloadsMutex.Unlock()
+
+	// Test with downloads
+	out = tools.toolOutWithDownloads("done")
+	result := out.LLMContent[0].Text
+	if !strings.Contains(result, "Downloads completed:") {
+		t.Errorf("Expected downloads section, got: %s", result)
+	}
+	if !strings.Contains(result, "test.txt") {
+		t.Errorf("Expected filename in output, got: %s", result)
+	}
+	if !strings.Contains(result, "http://example.com/files/test.txt") {
+		t.Errorf("Expected URL in output, got: %s", result)
+	}
+	if !strings.Contains(result, "saved to:") {
+		t.Errorf("Expected 'saved to:' in output, got: %s", result)
+	}
+	if !strings.Contains(result, "/tmp/test_abc123.txt") {
+		t.Errorf("Expected final path in output, got: %s", result)
+	}
+
+	// Verify download was cleared after retrieval
+	tools.downloadsMutex.Lock()
+	_, exists := tools.downloads["guid1"]
+	tools.downloadsMutex.Unlock()
+	if exists {
+		t.Error("Expected download to be cleared after retrieval")
+	}
+}
+
+// TestBrowserDownload tests the full browser download workflow with a real HTTP server
+func TestBrowserDownload(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping browser download test in short mode")
+	}
+
+	// Start a test HTTP server that triggers a download
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Failed to start listener: %v", err)
+	}
+	port := listener.Addr().(*net.TCPAddr).Port
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Disposition", "attachment; filename=\"test.txt\"")
+		w.Header().Set("Content-Type", "text/plain")
+		w.Write([]byte("Hello, this is a test file!"))
+	})
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/html")
+		w.Write([]byte(fmt.Sprintf(`<!DOCTYPE html>
+<html>
+<body>
+<a id="download-link" href="/download">Download</a>
+</body>
+</html>`)))
+	})
+
+	server := &http.Server{Handler: mux}
+	go server.Serve(listener)
+	defer server.Close()
+
+	// Create browser tools
+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+	defer cancel()
+
+	tools := NewBrowseTools(ctx, 0, 0)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Navigate to the test page
+	navInput := []byte(fmt.Sprintf(`{"url": "http://127.0.0.1:%d/"}`, port))
+	toolOut := tools.NewNavigateTool().Run(ctx, navInput)
+	if toolOut.Error != nil {
+		if strings.Contains(toolOut.Error.Error(), "failed to start browser") {
+			t.Skip("Browser automation not available in this environment")
+		}
+		t.Fatalf("Navigation error: %v", toolOut.Error)
+	}
+
+	// Click the download link
+	evalInput := []byte(`{"expression": "document.getElementById('download-link').click()"}`)
+	toolOut = tools.NewEvalTool().Run(ctx, evalInput)
+	if toolOut.Error != nil {
+		t.Fatalf("Eval error: %v", toolOut.Error)
+	}
+
+	// Wait for download to complete (poll for completion)
+	var downloadFound bool
+	for i := 0; i < 20; i++ {
+		time.Sleep(100 * time.Millisecond)
+		files, err := os.ReadDir(DownloadDir)
+		if err != nil {
+			continue
+		}
+		for _, f := range files {
+			// Check for renamed file (test_*) or GUID file
+			if strings.HasPrefix(f.Name(), "test_") || len(f.Name()) == 36 {
+				filePath := filepath.Join(DownloadDir, f.Name())
+				content, err := os.ReadFile(filePath)
+				if err == nil && string(content) == "Hello, this is a test file!" {
+					downloadFound = true
+					t.Logf("Found downloaded file: %s", f.Name())
+					// Clean up
+					os.Remove(filePath)
+					break
+				}
+			}
+		}
+		if downloadFound {
+			break
+		}
+	}
+
+	if !downloadFound {
+		// List what's in the directory for debugging
+		files, _ := os.ReadDir(DownloadDir)
+		var names []string
+		for _, f := range files {
+			names = append(names, f.Name())
+		}
+		t.Errorf("Download file not found. Files in %s: %v", DownloadDir, names)
+	}
+}
+
+// TestBrowserDownloadReported tests that downloads are reported in tool output
+func TestBrowserDownloadReported(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping browser download test in short mode")
+	}
+
+	// Start a test HTTP server that triggers a download
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Failed to start listener: %v", err)
+	}
+	port := listener.Addr().(*net.TCPAddr).Port
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Disposition", "attachment; filename=\"report_test.txt\"")
+		w.Header().Set("Content-Type", "text/plain")
+		w.Write([]byte("Download report test file content"))
+	})
+
+	server := &http.Server{Handler: mux}
+	go server.Serve(listener)
+	defer server.Close()
+
+	// Create browser tools
+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+	defer cancel()
+
+	tools := NewBrowseTools(ctx, 0, 0)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Navigate directly to the download URL - should succeed with download info
+	navInput := []byte(fmt.Sprintf(`{"url": "http://127.0.0.1:%d/download"}`, port))
+	toolOut := tools.NewNavigateTool().Run(ctx, navInput)
+	if toolOut.Error != nil {
+		if strings.Contains(toolOut.Error.Error(), "failed to start browser") {
+			t.Skip("Browser automation not available in this environment")
+		}
+		t.Fatalf("Navigation returned unexpected error: %v", toolOut.Error)
+	}
+
+	result := toolOut.LLMContent[0].Text
+	t.Logf("Navigation result: %s", result)
+
+	// Navigation to download URL should report the download directly
+	if !strings.Contains(result, "download") {
+		t.Errorf("Expected 'download' in output, got: %s", result)
+	}
+	if !strings.Contains(result, "report_test") {
+		t.Errorf("Expected 'report_test' in download output, got: %s", result)
+	}
+	if !strings.Contains(result, DownloadDir) {
+		t.Errorf("Expected download path, got: %s", result)
+	}
+
+	// Clean up any downloaded files
+	files, _ := os.ReadDir(DownloadDir)
+	for _, f := range files {
+		if strings.HasPrefix(f.Name(), "report_test_") {
+			os.Remove(filepath.Join(DownloadDir, f.Name()))
+		}
+	}
+}
+
+// TestLargeJSOutputWriteToFile tests that large JS eval results are written to file
+func TestLargeJSOutputWriteToFile(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping browser test in short mode")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+	defer cancel()
+
+	tools := NewBrowseTools(ctx, 0, 0)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Navigate to about:blank first
+	navInput := []byte(`{"url": "about:blank"}`)
+	toolOut := tools.NewNavigateTool().Run(ctx, navInput)
+	if toolOut.Error != nil {
+		if strings.Contains(toolOut.Error.Error(), "failed to start browser") {
+			t.Skip("Browser automation not available in this environment")
+		}
+		t.Fatalf("Navigation error: %v", toolOut.Error)
+	}
+
+	// Execute JS that returns a large string (> 1KB)
+	evalInput := []byte(`{"expression": "'x'.repeat(2000)"}`)
+	toolOut = tools.NewEvalTool().Run(ctx, evalInput)
+	if toolOut.Error != nil {
+		t.Fatalf("Eval error: %v", toolOut.Error)
+	}
+
+	result := toolOut.LLMContent[0].Text
+	t.Logf("Result: %s", result[:min(200, len(result))])
+
+	// Should be written to file
+	if !strings.Contains(result, "JavaScript result") {
+		t.Errorf("Expected 'JavaScript result' in output, got: %s", result)
+	}
+	if !strings.Contains(result, "written to:") {
+		t.Errorf("Expected 'written to:' in output, got: %s", result)
+	}
+	if !strings.Contains(result, ConsoleLogsDir) {
+		t.Errorf("Expected file path to contain %s, got: %s", ConsoleLogsDir, result)
+	}
+
+	// Extract and verify file exists
+	parts := strings.Split(result, "written to: ")
+	if len(parts) >= 2 {
+		filePath := strings.Split(parts[1], "\n")[0]
+		if _, err := os.Stat(filePath); os.IsNotExist(err) {
+			t.Errorf("Expected file to exist at %s", filePath)
+		} else {
+			// Verify content
+			content, err := os.ReadFile(filePath)
+			if err != nil {
+				t.Errorf("Failed to read file: %v", err)
+			} else if len(content) < 2000 {
+				t.Errorf("Expected file to contain large result, got %d bytes", len(content))
+			}
+			// Clean up
+			os.Remove(filePath)
+		}
+	}
+}
+
+// TestSmallJSOutputInline tests that small JS results are returned inline
+func TestSmallJSOutputInline(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping browser test in short mode")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+	defer cancel()
+
+	tools := NewBrowseTools(ctx, 0, 0)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Navigate to about:blank first
+	navInput := []byte(`{"url": "about:blank"}`)
+	toolOut := tools.NewNavigateTool().Run(ctx, navInput)
+	if toolOut.Error != nil {
+		if strings.Contains(toolOut.Error.Error(), "failed to start browser") {
+			t.Skip("Browser automation not available in this environment")
+		}
+		t.Fatalf("Navigation error: %v", toolOut.Error)
+	}
+
+	// Execute JS that returns a small string (< 1KB)
+	evalInput := []byte(`{"expression": "'hello world'"}`)
+	toolOut = tools.NewEvalTool().Run(ctx, evalInput)
+	if toolOut.Error != nil {
+		t.Fatalf("Eval error: %v", toolOut.Error)
+	}
+
+	result := toolOut.LLMContent[0].Text
+
+	// Should be inline
+	if !strings.Contains(result, "<javascript_result>") {
+		t.Errorf("Expected '<javascript_result>' in output, got: %s", result)
+	}
+	if !strings.Contains(result, "hello world") {
+		t.Errorf("Expected 'hello world' in output, got: %s", result)
+	}
+	if strings.Contains(result, "written to:") {
+		t.Errorf("Small result should not be written to file, got: %s", result)
+	}
+}