diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 6f79d21df..fc6b51411 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -91,6 +91,33 @@ func (r *recordingProvider) GetDefaultModel() string { return "mock-model" } +type imageFallbackProbeProvider struct { + calls []string +} + +func (p *imageFallbackProbeProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.calls = append(p.calls, model) + if model == "gemini-2.5-flash-lite" { + // This error is non-retriable in text fallback Execute(), but should still + // fail over in ExecuteImage() which only treats image size/dimension as terminal. + return nil, fmt.Errorf("string should match pattern") + } + return &providers.LLMResponse{ + Content: "image fallback ok", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (p *imageFallbackProbeProvider) GetDefaultModel() string { + return "image-fallback-probe" +} + func newTestAgentLoop( t *testing.T, ) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) { @@ -620,6 +647,54 @@ func TestRunAgentLoop_DoesNotUseImageModelWhenOnlyHistoryHasImages(t *testing.T) } } +func TestRunAgentLoop_UsesImageFallbackExecutionForImageCandidates(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "text-main", + ImageModel: "vision-main", + ImageModelFallbacks: []string{"vision-backup"}, + MaxTokens: 4096, + MaxToolIterations: 5, + }, + }, + ModelList: []*config.ModelConfig{ + {ModelName: "text-main", Model: "openai/gpt-5.4"}, + {ModelName: "vision-main", Model: "gemini/gemini-2.5-flash-lite"}, + {ModelName: "vision-backup", Model: "anthropic/claude-3-7-sonnet"}, + }, + } + + msgBus := bus.NewMessageBus() + provider := &imageFallbackProbeProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + agent := al.registry.GetDefaultAgent() + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "image-fallback-session", + Channel: "telegram", + ChatID: "chat-1", + UserMessage: "describe this image", + Media: []string{"data:image/png;base64,AAAA"}, + DefaultResponse: "fallback", + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop returned error: %v", err) + } + if resp != "image fallback ok" { + t.Fatalf("response = %q, want %q", resp, "image fallback ok") + } + if len(provider.calls) != 2 { + t.Fatalf("provider calls = %d, want 2", len(provider.calls)) + } + if provider.calls[0] != "gemini-2.5-flash-lite" || provider.calls[1] != "claude-3-7-sonnet" { + t.Fatalf("provider calls = %v, want [gemini-2.5-flash-lite claude-3-7-sonnet]", provider.calls) + } +} + func TestNewAgentLoop_StateInitialized(t *testing.T) { // Create temp workspace tmpDir, err := os.MkdirTemp("", "agent-test-*")