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