predictable.go

  1package loop
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"os"
  8	"strconv"
  9	"strings"
 10	"sync"
 11	"time"
 12
 13	"shelley.exe.dev/llm"
 14)
 15
 16// PredictableService is an LLM service that returns predictable responses for testing.
 17//
 18// To add new test patterns, update the Do() method directly by adding cases to the switch
 19// statement or new prefix checks. Do not extend or wrap this service - modify it in place.
 20// Available patterns include:
 21//   - "echo: <text>" - echoes the text back
 22//   - "bash: <command>" - triggers bash tool with command
 23//   - "think: <thoughts>" - triggers think tool
 24//   - "subagent: <slug> <prompt>" - triggers subagent tool
 25//   - "delay: <seconds>" - delays response by specified seconds
 26//   - See Do() method for complete list of supported patterns
 27type PredictableService struct {
 28	// TokenContextWindow size
 29	tokenContextWindow int
 30	mu                 sync.Mutex
 31	// Recent requests for testing inspection
 32	recentRequests []*llm.Request
 33	responseDelay  time.Duration
 34}
 35
 36// NewPredictableService creates a new predictable LLM service
 37func NewPredictableService() *PredictableService {
 38	svc := &PredictableService{
 39		tokenContextWindow: 200000,
 40	}
 41
 42	if delayEnv := os.Getenv("PREDICTABLE_DELAY_MS"); delayEnv != "" {
 43		if ms, err := strconv.Atoi(delayEnv); err == nil && ms > 0 {
 44			svc.responseDelay = time.Duration(ms) * time.Millisecond
 45		}
 46	}
 47
 48	return svc
 49}
 50
 51// TokenContextWindow returns the maximum token context window size
 52func (s *PredictableService) TokenContextWindow() int {
 53	return s.tokenContextWindow
 54}
 55
 56// MaxImageDimension returns the maximum allowed image dimension.
 57func (s *PredictableService) MaxImageDimension() int {
 58	return 2000
 59}
 60
 61// Do processes a request and returns a predictable response based on the input text
 62func (s *PredictableService) Do(ctx context.Context, req *llm.Request) (*llm.Response, error) {
 63	// Store request for testing inspection
 64	s.mu.Lock()
 65	delay := s.responseDelay
 66	s.recentRequests = append(s.recentRequests, req)
 67	// Keep only last 10 requests
 68	if len(s.recentRequests) > 10 {
 69		s.recentRequests = s.recentRequests[len(s.recentRequests)-10:]
 70	}
 71	s.mu.Unlock()
 72
 73	if delay > 0 {
 74		select {
 75		case <-time.After(delay):
 76		case <-ctx.Done():
 77			return nil, ctx.Err()
 78		}
 79	}
 80
 81	// Calculate input token count based on the request content
 82	inputTokens := s.countRequestTokens(req)
 83
 84	// Extract the text content from the last user message
 85	var inputText string
 86	if len(req.Messages) > 0 {
 87		lastMessage := req.Messages[len(req.Messages)-1]
 88		if lastMessage.Role == llm.MessageRoleUser {
 89			for _, content := range lastMessage.Content {
 90				if content.Type == llm.ContentTypeText {
 91					inputText = strings.TrimSpace(content.Text)
 92					break
 93				}
 94			}
 95		}
 96	}
 97
 98	// Handle input using case statements
 99	switch inputText {
100	case "hello":
101		return s.makeResponse("Well, hi there!", inputTokens), nil
102
103	case "Hello":
104		return s.makeResponse("Hello! I'm Shelley, your AI assistant. How can I help you today?", inputTokens), nil
105
106	case "Create an example":
107		return s.makeThinkToolResponse("I'll create a simple example for you.", inputTokens), nil
108
109	case "screenshot":
110		// Trigger a screenshot of the current page
111		return s.makeScreenshotToolResponse("", inputTokens), nil
112
113	case "tool smorgasbord":
114		// Return a response with all tool types for testing
115		return s.makeToolSmorgasbordResponse(inputTokens), nil
116
117	case "echo: foo":
118		return s.makeResponse("foo", inputTokens), nil
119
120	case "patch fail":
121		// Trigger a patch that will fail (file doesn't exist)
122		return s.makePatchToolResponse("/nonexistent/file/that/does/not/exist.txt", inputTokens), nil
123
124	case "patch success":
125		// Trigger a patch that will succeed (using overwrite, which creates the file)
126		return s.makePatchToolResponseOverwrite("/tmp/test-patch-success.txt", inputTokens), nil
127
128	case "patch bad json":
129		// Trigger a patch with malformed JSON (simulates Anthropic sending invalid JSON)
130		return s.makeMalformedPatchToolResponse(inputTokens), nil
131
132	case "maxTokens":
133		// Simulate a max_tokens truncation
134		return s.makeMaxTokensResponse("This is a truncated response that was cut off mid-sentence because the output token limit was", inputTokens), nil
135
136	default:
137		// Handle pattern-based inputs
138		if strings.HasPrefix(inputText, "echo: ") {
139			text := strings.TrimPrefix(inputText, "echo: ")
140			return s.makeResponse(text, inputTokens), nil
141		}
142
143		if strings.HasPrefix(inputText, "bash: ") {
144			cmd := strings.TrimPrefix(inputText, "bash: ")
145			return s.makeBashToolResponse(cmd, inputTokens), nil
146		}
147
148		if strings.HasPrefix(inputText, "think: ") {
149			thoughts := strings.TrimPrefix(inputText, "think: ")
150			return s.makeThinkToolResponse(thoughts, inputTokens), nil
151		}
152
153		if strings.HasPrefix(inputText, "patch: ") {
154			filePath := strings.TrimPrefix(inputText, "patch: ")
155			return s.makePatchToolResponse(filePath, inputTokens), nil
156		}
157
158		if strings.HasPrefix(inputText, "error: ") {
159			errorMsg := strings.TrimPrefix(inputText, "error: ")
160			return nil, fmt.Errorf("predictable error: %s", errorMsg)
161		}
162
163		if strings.HasPrefix(inputText, "screenshot: ") {
164			selector := strings.TrimSpace(strings.TrimPrefix(inputText, "screenshot: "))
165			return s.makeScreenshotToolResponse(selector, inputTokens), nil
166		}
167
168		if strings.HasPrefix(inputText, "subagent: ") {
169			// Format: "subagent: <slug> <prompt>"
170			parts := strings.SplitN(strings.TrimPrefix(inputText, "subagent: "), " ", 2)
171			slug := parts[0]
172			prompt := "do the task"
173			if len(parts) > 1 {
174				prompt = parts[1]
175			}
176			return s.makeSubagentToolResponse(slug, prompt, inputTokens), nil
177		}
178
179		if strings.HasPrefix(inputText, "delay: ") {
180			delayStr := strings.TrimPrefix(inputText, "delay: ")
181			delaySeconds, err := strconv.ParseFloat(delayStr, 64)
182			if err == nil && delaySeconds > 0 {
183				delayDuration := time.Duration(delaySeconds * float64(time.Second))
184				select {
185				case <-time.After(delayDuration):
186				case <-ctx.Done():
187					return nil, ctx.Err()
188				}
189			}
190			return s.makeResponse(fmt.Sprintf("Delayed for %s seconds", delayStr), inputTokens), nil
191		}
192
193		// Default response for undefined inputs
194		return s.makeResponse("edit predictable.go to add a response for that one...", inputTokens), nil
195	}
196}
197
198// makeMaxTokensResponse creates a response that simulates hitting max_tokens limit
199func (s *PredictableService) makeMaxTokensResponse(text string, inputTokens uint64) *llm.Response {
200	outputTokens := uint64(len(text) / 4)
201	if outputTokens == 0 {
202		outputTokens = 1
203	}
204	return &llm.Response{
205		ID:    fmt.Sprintf("pred-%d", time.Now().UnixNano()),
206		Type:  "message",
207		Role:  llm.MessageRoleAssistant,
208		Model: "predictable-v1",
209		Content: []llm.Content{
210			{Type: llm.ContentTypeText, Text: text},
211		},
212		StopReason: llm.StopReasonMaxTokens,
213		Usage: llm.Usage{
214			InputTokens:  inputTokens,
215			OutputTokens: outputTokens,
216			CostUSD:      0.001,
217		},
218	}
219}
220
221// makeResponse creates a simple text response
222func (s *PredictableService) makeResponse(text string, inputTokens uint64) *llm.Response {
223	outputTokens := uint64(len(text) / 4) // ~4 chars per token
224	if outputTokens == 0 {
225		outputTokens = 1
226	}
227	return &llm.Response{
228		ID:    fmt.Sprintf("pred-%d", time.Now().UnixNano()),
229		Type:  "message",
230		Role:  llm.MessageRoleAssistant,
231		Model: "predictable-v1",
232		Content: []llm.Content{
233			{Type: llm.ContentTypeText, Text: text},
234		},
235		StopReason: llm.StopReasonStopSequence,
236		Usage: llm.Usage{
237			InputTokens:  inputTokens,
238			OutputTokens: outputTokens,
239			CostUSD:      0.001,
240		},
241	}
242}
243
244// makeBashToolResponse creates a response that calls the bash tool
245func (s *PredictableService) makeBashToolResponse(command string, inputTokens uint64) *llm.Response {
246	// Properly marshal the command to avoid JSON escaping issues
247	toolInputData := map[string]string{"command": command}
248	toolInputBytes, _ := json.Marshal(toolInputData)
249	toolInput := json.RawMessage(toolInputBytes)
250	responseText := fmt.Sprintf("I'll run the command: %s", command)
251	outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
252	if outputTokens == 0 {
253		outputTokens = 1
254	}
255	return &llm.Response{
256		ID:    fmt.Sprintf("pred-bash-%d", time.Now().UnixNano()),
257		Type:  "message",
258		Role:  llm.MessageRoleAssistant,
259		Model: "predictable-v1",
260		Content: []llm.Content{
261			{Type: llm.ContentTypeText, Text: responseText},
262			{
263				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
264				Type:      llm.ContentTypeToolUse,
265				ToolName:  "bash",
266				ToolInput: toolInput,
267			},
268		},
269		StopReason: llm.StopReasonToolUse,
270		Usage: llm.Usage{
271			InputTokens:  inputTokens,
272			OutputTokens: outputTokens,
273			CostUSD:      0.002,
274		},
275	}
276}
277
278// makeThinkToolResponse creates a response that calls the think tool
279func (s *PredictableService) makeThinkToolResponse(thoughts string, inputTokens uint64) *llm.Response {
280	// Properly marshal the thoughts to avoid JSON escaping issues
281	toolInputData := map[string]string{"thoughts": thoughts}
282	toolInputBytes, _ := json.Marshal(toolInputData)
283	toolInput := json.RawMessage(toolInputBytes)
284	responseText := "Let me think about this."
285	outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
286	if outputTokens == 0 {
287		outputTokens = 1
288	}
289	return &llm.Response{
290		ID:    fmt.Sprintf("pred-think-%d", time.Now().UnixNano()),
291		Type:  "message",
292		Role:  llm.MessageRoleAssistant,
293		Model: "predictable-v1",
294		Content: []llm.Content{
295			{Type: llm.ContentTypeText, Text: responseText},
296			{
297				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
298				Type:      llm.ContentTypeToolUse,
299				ToolName:  "think",
300				ToolInput: toolInput,
301			},
302		},
303		StopReason: llm.StopReasonToolUse,
304		Usage: llm.Usage{
305			InputTokens:  inputTokens,
306			OutputTokens: outputTokens,
307			CostUSD:      0.002,
308		},
309	}
310}
311
312// makePatchToolResponse creates a response that calls the patch tool
313func (s *PredictableService) makePatchToolResponse(filePath string, inputTokens uint64) *llm.Response {
314	// Properly marshal the patch data to avoid JSON escaping issues
315	toolInputData := map[string]interface{}{
316		"path": filePath,
317		"patches": []map[string]string{
318			{
319				"operation": "replace",
320				"oldText":   "example",
321				"newText":   "updated example",
322			},
323		},
324	}
325	toolInputBytes, _ := json.Marshal(toolInputData)
326	toolInput := json.RawMessage(toolInputBytes)
327	responseText := fmt.Sprintf("I'll patch the file: %s", filePath)
328	outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
329	if outputTokens == 0 {
330		outputTokens = 1
331	}
332	return &llm.Response{
333		ID:    fmt.Sprintf("pred-patch-%d", time.Now().UnixNano()),
334		Type:  "message",
335		Role:  llm.MessageRoleAssistant,
336		Model: "predictable-v1",
337		Content: []llm.Content{
338			{Type: llm.ContentTypeText, Text: responseText},
339			{
340				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
341				Type:      llm.ContentTypeToolUse,
342				ToolName:  "patch",
343				ToolInput: toolInput,
344			},
345		},
346		StopReason: llm.StopReasonToolUse,
347		Usage: llm.Usage{
348			InputTokens:  inputTokens,
349			OutputTokens: outputTokens,
350			CostUSD:      0.003,
351		},
352	}
353}
354
355// makePatchToolResponseOverwrite creates a response that uses overwrite operation (always succeeds)
356func (s *PredictableService) makePatchToolResponseOverwrite(filePath string, inputTokens uint64) *llm.Response {
357	toolInputData := map[string]interface{}{
358		"path": filePath,
359		"patches": []map[string]string{
360			{
361				"operation": "overwrite",
362				"newText":   "This is the new content of the file.\nLine 2\nLine 3\n",
363			},
364		},
365	}
366	toolInputBytes, _ := json.Marshal(toolInputData)
367	toolInput := json.RawMessage(toolInputBytes)
368	responseText := fmt.Sprintf("I'll create/overwrite the file: %s", filePath)
369	outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
370	if outputTokens == 0 {
371		outputTokens = 1
372	}
373	return &llm.Response{
374		ID:    fmt.Sprintf("pred-patch-overwrite-%d", time.Now().UnixNano()),
375		Type:  "message",
376		Role:  llm.MessageRoleAssistant,
377		Model: "predictable-v1",
378		Content: []llm.Content{
379			{Type: llm.ContentTypeText, Text: responseText},
380			{
381				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
382				Type:      llm.ContentTypeToolUse,
383				ToolName:  "patch",
384				ToolInput: toolInput,
385			},
386		},
387		StopReason: llm.StopReasonToolUse,
388		Usage: llm.Usage{
389			InputTokens:  inputTokens,
390			OutputTokens: outputTokens,
391			CostUSD:      0.0,
392		},
393	}
394}
395
396// makeMalformedPatchToolResponse creates a response with malformed JSON that will fail to parse
397// This simulates when Anthropic sends back invalid JSON in the tool input
398func (s *PredictableService) makeMalformedPatchToolResponse(inputTokens uint64) *llm.Response {
399	// This malformed JSON has a string where an object is expected (patch field)
400	// Mimics the error: "cannot unmarshal string into Go struct field PatchInputOneSingular.patch"
401	malformedJSON := `{"path":"/home/agent/example.css","patch":"<parameter name=\"operation\">replace","oldText":".example {\n  color: red;\n}","newText":".example {\n  color: blue;\n}"}`
402	toolInput := json.RawMessage(malformedJSON)
403	return &llm.Response{
404		ID:    fmt.Sprintf("pred-patch-malformed-%d", time.Now().UnixNano()),
405		Type:  "message",
406		Role:  llm.MessageRoleAssistant,
407		Model: "predictable-v1",
408		Content: []llm.Content{
409			{Type: llm.ContentTypeText, Text: "I'll patch the file with the changes."},
410			{
411				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
412				Type:      llm.ContentTypeToolUse,
413				ToolName:  "patch",
414				ToolInput: toolInput,
415			},
416		},
417		StopReason: llm.StopReasonToolUse,
418		Usage: llm.Usage{
419			InputTokens:  inputTokens,
420			OutputTokens: 50,
421			CostUSD:      0.003,
422		},
423	}
424}
425
426// GetRecentRequests returns the recent requests made to this service
427func (s *PredictableService) GetRecentRequests() []*llm.Request {
428	s.mu.Lock()
429	defer s.mu.Unlock()
430
431	if len(s.recentRequests) == 0 {
432		return nil
433	}
434
435	requests := make([]*llm.Request, len(s.recentRequests))
436	copy(requests, s.recentRequests)
437	return requests
438}
439
440// GetLastRequest returns the most recent request, or nil if none
441func (s *PredictableService) GetLastRequest() *llm.Request {
442	s.mu.Lock()
443	defer s.mu.Unlock()
444
445	if len(s.recentRequests) == 0 {
446		return nil
447	}
448	return s.recentRequests[len(s.recentRequests)-1]
449}
450
451// ClearRequests clears the request history
452func (s *PredictableService) ClearRequests() {
453	s.mu.Lock()
454	defer s.mu.Unlock()
455
456	s.recentRequests = nil
457}
458
459// countRequestTokens estimates token count based on character count.
460// Uses a simple ~4 chars per token approximation.
461func (s *PredictableService) countRequestTokens(req *llm.Request) uint64 {
462	var totalChars int
463
464	// Count system prompt characters
465	for _, sys := range req.System {
466		totalChars += len(sys.Text)
467	}
468
469	// Count message characters
470	for _, msg := range req.Messages {
471		for _, content := range msg.Content {
472			switch content.Type {
473			case llm.ContentTypeText:
474				totalChars += len(content.Text)
475			case llm.ContentTypeToolUse:
476				totalChars += len(content.ToolName)
477				totalChars += len(content.ToolInput)
478			case llm.ContentTypeToolResult:
479				for _, result := range content.ToolResult {
480					if result.Type == llm.ContentTypeText {
481						totalChars += len(result.Text)
482					}
483				}
484			}
485		}
486	}
487
488	// Count tool definitions
489	for _, tool := range req.Tools {
490		totalChars += len(tool.Name)
491		totalChars += len(tool.Description)
492		totalChars += len(tool.InputSchema)
493	}
494
495	// ~4 chars per token is a rough approximation
496	return uint64(totalChars / 4)
497}
498
499// makeScreenshotToolResponse creates a response that calls the screenshot tool
500func (s *PredictableService) makeScreenshotToolResponse(selector string, inputTokens uint64) *llm.Response {
501	toolInputData := map[string]any{}
502	if selector != "" {
503		toolInputData["selector"] = selector
504	}
505	toolInputBytes, _ := json.Marshal(toolInputData)
506	toolInput := json.RawMessage(toolInputBytes)
507	responseText := "Taking a screenshot..."
508	outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
509	if outputTokens == 0 {
510		outputTokens = 1
511	}
512	return &llm.Response{
513		ID:    fmt.Sprintf("pred-screenshot-%d", time.Now().UnixNano()),
514		Type:  "message",
515		Role:  llm.MessageRoleAssistant,
516		Model: "predictable-v1",
517		Content: []llm.Content{
518			{Type: llm.ContentTypeText, Text: responseText},
519			{
520				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
521				Type:      llm.ContentTypeToolUse,
522				ToolName:  "browser_take_screenshot",
523				ToolInput: toolInput,
524			},
525		},
526		StopReason: llm.StopReasonToolUse,
527		Usage: llm.Usage{
528			InputTokens:  inputTokens,
529			OutputTokens: outputTokens,
530			CostUSD:      0.0,
531		},
532	}
533}
534
535func (s *PredictableService) makeSubagentToolResponse(slug, prompt string, inputTokens uint64) *llm.Response {
536	toolInputData := map[string]any{
537		"slug":   slug,
538		"prompt": prompt,
539	}
540	toolInputBytes, _ := json.Marshal(toolInputData)
541	toolInput := json.RawMessage(toolInputBytes)
542	responseText := fmt.Sprintf("Delegating to subagent '%s'...", slug)
543	outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
544	if outputTokens == 0 {
545		outputTokens = 1
546	}
547	return &llm.Response{
548		ID:    fmt.Sprintf("pred-subagent-%d", time.Now().UnixNano()),
549		Type:  "message",
550		Role:  llm.MessageRoleAssistant,
551		Model: "predictable-v1",
552		Content: []llm.Content{
553			{Type: llm.ContentTypeText, Text: responseText},
554			{
555				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
556				Type:      llm.ContentTypeToolUse,
557				ToolName:  "subagent",
558				ToolInput: toolInput,
559			},
560		},
561		StopReason: llm.StopReasonToolUse,
562		Usage: llm.Usage{
563			InputTokens:  inputTokens,
564			OutputTokens: outputTokens,
565			CostUSD:      0.0,
566		},
567	}
568}
569
570// makeToolSmorgasbordResponse creates a response that uses all available tool types
571func (s *PredictableService) makeToolSmorgasbordResponse(inputTokens uint64) *llm.Response {
572	baseNano := time.Now().UnixNano()
573	content := []llm.Content{
574		{Type: llm.ContentTypeText, Text: "Here's a sample of all the tools:"},
575	}
576
577	// bash tool
578	bashInput, _ := json.Marshal(map[string]string{"command": "echo 'hello from bash'"})
579	content = append(content, llm.Content{
580		ID:        fmt.Sprintf("tool_bash_%d", baseNano%1000),
581		Type:      llm.ContentTypeToolUse,
582		ToolName:  "bash",
583		ToolInput: json.RawMessage(bashInput),
584	})
585
586	// think tool
587	thinkInput, _ := json.Marshal(map[string]string{"thoughts": "I'm thinking about the best approach for this task. Let me consider all the options available."})
588	content = append(content, llm.Content{
589		ID:        fmt.Sprintf("tool_think_%d", (baseNano+1)%1000),
590		Type:      llm.ContentTypeToolUse,
591		ToolName:  "think",
592		ToolInput: json.RawMessage(thinkInput),
593	})
594
595	// patch tool
596	patchInput, _ := json.Marshal(map[string]interface{}{
597		"path": "/tmp/example.txt",
598		"patches": []map[string]string{
599			{"operation": "replace", "oldText": "foo", "newText": "bar"},
600		},
601	})
602	content = append(content, llm.Content{
603		ID:        fmt.Sprintf("tool_patch_%d", (baseNano+2)%1000),
604		Type:      llm.ContentTypeToolUse,
605		ToolName:  "patch",
606		ToolInput: json.RawMessage(patchInput),
607	})
608
609	// screenshot tool
610	screenshotInput, _ := json.Marshal(map[string]string{})
611	content = append(content, llm.Content{
612		ID:        fmt.Sprintf("tool_screenshot_%d", (baseNano+3)%1000),
613		Type:      llm.ContentTypeToolUse,
614		ToolName:  "browser_take_screenshot",
615		ToolInput: json.RawMessage(screenshotInput),
616	})
617
618	// keyword_search tool
619	keywordInput, _ := json.Marshal(map[string]interface{}{
620		"query":        "find all references",
621		"search_terms": []string{"reference", "example"},
622	})
623	content = append(content, llm.Content{
624		ID:        fmt.Sprintf("tool_keyword_%d", (baseNano+4)%1000),
625		Type:      llm.ContentTypeToolUse,
626		ToolName:  "keyword_search",
627		ToolInput: json.RawMessage(keywordInput),
628	})
629
630	// browser_navigate tool
631	navigateInput, _ := json.Marshal(map[string]string{"url": "https://example.com"})
632	content = append(content, llm.Content{
633		ID:        fmt.Sprintf("tool_navigate_%d", (baseNano+5)%1000),
634		Type:      llm.ContentTypeToolUse,
635		ToolName:  "browser_navigate",
636		ToolInput: json.RawMessage(navigateInput),
637	})
638
639	// browser_eval tool
640	evalInput, _ := json.Marshal(map[string]string{"expression": "document.title"})
641	content = append(content, llm.Content{
642		ID:        fmt.Sprintf("tool_eval_%d", (baseNano+6)%1000),
643		Type:      llm.ContentTypeToolUse,
644		ToolName:  "browser_eval",
645		ToolInput: json.RawMessage(evalInput),
646	})
647
648	// read_image tool
649	readImageInput, _ := json.Marshal(map[string]string{"path": "/tmp/image.png"})
650	content = append(content, llm.Content{
651		ID:        fmt.Sprintf("tool_readimg_%d", (baseNano+7)%1000),
652		Type:      llm.ContentTypeToolUse,
653		ToolName:  "read_image",
654		ToolInput: json.RawMessage(readImageInput),
655	})
656
657	// browser_recent_console_logs tool
658	consoleInput, _ := json.Marshal(map[string]string{})
659	content = append(content, llm.Content{
660		ID:        fmt.Sprintf("tool_console_%d", (baseNano+8)%1000),
661		Type:      llm.ContentTypeToolUse,
662		ToolName:  "browser_recent_console_logs",
663		ToolInput: json.RawMessage(consoleInput),
664	})
665
666	return &llm.Response{
667		ID:         fmt.Sprintf("pred-smorgasbord-%d", baseNano),
668		Type:       "message",
669		Role:       llm.MessageRoleAssistant,
670		Model:      "predictable-v1",
671		Content:    content,
672		StopReason: llm.StopReasonToolUse,
673		Usage: llm.Usage{
674			InputTokens:  inputTokens,
675			OutputTokens: 200,
676			CostUSD:      0.01,
677		},
678	}
679}