forked from Rockachopa/Timmy-time-dashboard
538 lines
20 KiB
Python
538 lines
20 KiB
Python
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
def test_create_timmy_custom_db_file():
|
|
with (
|
|
patch("timmy.agent.Agent"),
|
|
patch("timmy.agent.Ollama"),
|
|
patch("timmy.agent.SqliteDb") as MockDb,
|
|
patch("timmy.agent._resolve_model_with_fallback", return_value=("llama3.2:3b", False)),
|
|
patch("timmy.agent._check_model_available", return_value=True),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
from timmy.agent import create_timmy
|
|
|
|
create_timmy(db_file="custom.db")
|
|
|
|
MockDb.assert_called_once_with(db_file="custom.db")
|
|
|
|
|
|
def test_create_timmy_embeds_system_prompt():
|
|
|
|
with (
|
|
patch("timmy.agent.Agent") as MockAgent,
|
|
patch("timmy.agent.Ollama"),
|
|
patch("timmy.agent.SqliteDb"),
|
|
patch("timmy.agent._resolve_model_with_fallback", return_value=("llama3.2:3b", False)),
|
|
patch("timmy.agent._check_model_available", return_value=True),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
from timmy.agent import create_timmy
|
|
|
|
create_timmy()
|
|
|
|
kwargs = MockAgent.call_args.kwargs
|
|
# Prompt should contain base system prompt (may have memory context appended)
|
|
# Default model (llama3.2) uses the lite prompt
|
|
assert "Timmy" in kwargs["description"]
|
|
|
|
|
|
# ── Ollama host regression (container connectivity) ─────────────────────────
|
|
|
|
|
|
def test_create_timmy_passes_ollama_url_to_model():
|
|
"""Regression: Ollama model must receive settings.ollama_url as host.
|
|
|
|
Without this, containers default to localhost:11434 which is unreachable
|
|
when Ollama runs on the Docker host.
|
|
"""
|
|
with (
|
|
patch("timmy.agent.Agent"),
|
|
patch("timmy.agent.Ollama") as MockOllama,
|
|
patch("timmy.agent.SqliteDb"),
|
|
patch("timmy.agent._resolve_model_with_fallback", return_value=("llama3.2:3b", False)),
|
|
patch("timmy.agent._check_model_available", return_value=True),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
from timmy.agent import create_timmy
|
|
|
|
create_timmy()
|
|
|
|
kwargs = MockOllama.call_args.kwargs
|
|
assert "host" in kwargs, "Ollama() must receive host= parameter"
|
|
from config import settings
|
|
|
|
assert kwargs["host"] == settings.ollama_url
|
|
|
|
|
|
def test_create_timmy_respects_custom_ollama_url():
|
|
"""Ollama host should follow OLLAMA_URL when overridden in config."""
|
|
custom_url = "http://host.docker.internal:11434"
|
|
with (
|
|
patch("timmy.agent.Agent"),
|
|
patch("timmy.agent.Ollama") as MockOllama,
|
|
patch("timmy.agent.SqliteDb"),
|
|
patch("timmy.agent.settings") as mock_settings,
|
|
patch("timmy.agent._resolve_model_with_fallback", return_value=("llama3.2:3b", False)),
|
|
patch("timmy.agent._check_model_available", return_value=True),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
mock_settings.ollama_model = "llama3.2"
|
|
mock_settings.ollama_url = custom_url
|
|
mock_settings.ollama_num_ctx = 4096
|
|
mock_settings.timmy_model_backend = "ollama"
|
|
|
|
from timmy.agent import create_timmy
|
|
|
|
create_timmy()
|
|
|
|
kwargs = MockOllama.call_args.kwargs
|
|
assert kwargs["host"] == custom_url
|
|
|
|
|
|
def test_create_timmy_explicit_ollama_ignores_autodetect():
|
|
"""backend='ollama' must always use Ollama, even on Apple Silicon."""
|
|
with (
|
|
patch("timmy.agent.Agent") as MockAgent,
|
|
patch("timmy.agent.Ollama"),
|
|
patch("timmy.agent.SqliteDb"),
|
|
patch("timmy.agent._resolve_model_with_fallback", return_value=("llama3.2:3b", False)),
|
|
patch("timmy.agent._check_model_available", return_value=True),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
from timmy.agent import create_timmy
|
|
|
|
create_timmy(backend="ollama")
|
|
|
|
MockAgent.assert_called_once()
|
|
|
|
|
|
# ── _resolve_backend ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_resolve_backend_explicit_takes_priority():
|
|
from timmy.agent import _resolve_backend
|
|
|
|
assert _resolve_backend("ollama") == "ollama"
|
|
|
|
|
|
def test_resolve_backend_defaults_to_ollama_without_config():
|
|
"""Default config (timmy_model_backend='ollama') → 'ollama'."""
|
|
from timmy.agent import _resolve_backend
|
|
|
|
assert _resolve_backend(None) == "ollama"
|
|
|
|
|
|
def test_model_supports_tools_llama32_returns_false():
|
|
"""llama3.2 (3B) is too small for reliable tool calling."""
|
|
from timmy.agent import _model_supports_tools
|
|
|
|
assert _model_supports_tools("llama3.2") is False
|
|
assert _model_supports_tools("llama3.2:latest") is False
|
|
|
|
|
|
def test_model_supports_tools_llama31_returns_true():
|
|
"""llama3.1 (8B+) can handle tool calling."""
|
|
from timmy.agent import _model_supports_tools
|
|
|
|
assert _model_supports_tools("llama3.1") is True
|
|
assert _model_supports_tools("llama3.3") is True
|
|
|
|
|
|
def test_model_supports_tools_other_small_models():
|
|
"""Other known small models should not get tools."""
|
|
from timmy.agent import _model_supports_tools
|
|
|
|
assert _model_supports_tools("phi-3") is False
|
|
assert _model_supports_tools("tinyllama") is False
|
|
|
|
|
|
def test_model_supports_tools_unknown_model_gets_tools():
|
|
"""Unknown models default to tool-capable (optimistic)."""
|
|
from timmy.agent import _model_supports_tools
|
|
|
|
assert _model_supports_tools("mistral") is True
|
|
assert _model_supports_tools("qwen2.5:72b") is True
|
|
|
|
|
|
# ── Tool gating in create_timmy ──────────────────────────────────────────────
|
|
|
|
|
|
def test_create_timmy_no_tools_for_small_model():
|
|
"""Small models (llama3.2) should get no tools."""
|
|
mock_toolkit = MagicMock()
|
|
with (
|
|
patch("timmy.agent.Agent") as MockAgent,
|
|
patch("timmy.agent.Ollama"),
|
|
patch("timmy.agent.SqliteDb"),
|
|
patch("timmy.agent.create_full_toolkit", return_value=mock_toolkit),
|
|
patch("timmy.agent._resolve_model_with_fallback", return_value=("llama3.2:3b", False)),
|
|
patch("timmy.agent._check_model_available", return_value=True),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
from timmy.agent import create_timmy
|
|
|
|
create_timmy()
|
|
|
|
kwargs = MockAgent.call_args.kwargs
|
|
# llama3.2 is in _SMALL_MODEL_PATTERNS → tools should be None
|
|
assert kwargs["tools"] is None
|
|
|
|
|
|
def test_create_timmy_includes_tools_for_large_model():
|
|
"""A tool-capable model (e.g. llama3.1) should attempt to include tools."""
|
|
mock_toolkit = MagicMock()
|
|
|
|
with (
|
|
patch("timmy.agent.Agent") as MockAgent,
|
|
patch("timmy.agent.Ollama"),
|
|
patch("timmy.agent.SqliteDb"),
|
|
patch("timmy.agent.create_full_toolkit", return_value=mock_toolkit),
|
|
patch("timmy.agent.settings") as mock_settings,
|
|
patch("timmy.agent._resolve_model_with_fallback", return_value=("llama3.1", False)),
|
|
patch("timmy.agent._check_model_available", return_value=True),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
mock_settings.ollama_model = "llama3.1"
|
|
mock_settings.ollama_url = "http://localhost:11434"
|
|
mock_settings.ollama_num_ctx = 4096
|
|
mock_settings.timmy_model_backend = "ollama"
|
|
mock_settings.telemetry_enabled = False
|
|
|
|
from timmy.agent import create_timmy
|
|
|
|
create_timmy()
|
|
|
|
kwargs = MockAgent.call_args.kwargs
|
|
assert mock_toolkit in kwargs["tools"]
|
|
|
|
|
|
def test_create_timmy_no_unsupported_agent_kwargs():
|
|
"""Regression guard: show_tool_calls and tool_call_limit are not valid agno 2.x params.
|
|
|
|
These were removed in f95c960 (Feb 26) and must not be reintroduced.
|
|
"""
|
|
with (
|
|
patch("timmy.agent.Agent") as MockAgent,
|
|
patch("timmy.agent.Ollama"),
|
|
patch("timmy.agent.SqliteDb"),
|
|
patch("timmy.agent._resolve_model_with_fallback", return_value=("llama3.2:3b", False)),
|
|
patch("timmy.agent._check_model_available", return_value=True),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
from timmy.agent import create_timmy
|
|
|
|
create_timmy()
|
|
|
|
kwargs = MockAgent.call_args.kwargs
|
|
assert "show_tool_calls" not in kwargs, "show_tool_calls is not a valid Agent param"
|
|
|
|
|
|
def test_create_timmy_no_extra_kwargs():
|
|
"""All kwargs passed to Agent() must be from the known-valid set.
|
|
|
|
agno is mocked globally in conftest, so we can't inspect the real class here.
|
|
Instead, maintain an explicit allowlist of params validated against agno 2.5.5.
|
|
If a new param is needed, verify it exists in agno first, then add it here.
|
|
"""
|
|
VALID_AGENT_KWARGS = {
|
|
"name",
|
|
"model",
|
|
"db",
|
|
"description",
|
|
"add_history_to_context",
|
|
"num_history_runs",
|
|
"markdown",
|
|
"tools",
|
|
"tool_call_limit",
|
|
"telemetry",
|
|
}
|
|
|
|
with (
|
|
patch("timmy.agent.Agent") as MockAgent,
|
|
patch("timmy.agent.Ollama"),
|
|
patch("timmy.agent.SqliteDb"),
|
|
patch("timmy.agent._resolve_model_with_fallback", return_value=("llama3.2:3b", False)),
|
|
patch("timmy.agent._check_model_available", return_value=True),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
from timmy.agent import create_timmy
|
|
|
|
create_timmy()
|
|
|
|
kwargs = MockAgent.call_args.kwargs
|
|
invalid = set(kwargs.keys()) - VALID_AGENT_KWARGS
|
|
assert not invalid, (
|
|
f"Unknown Agent kwargs {invalid} — verify they exist in agno "
|
|
f"before adding to VALID_AGENT_KWARGS"
|
|
)
|
|
|
|
|
|
# ── skip_mcp flag (#72) ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_create_timmy_skip_mcp_omits_mcp_tools():
|
|
"""create_timmy(skip_mcp=True) must not add MCP tool servers."""
|
|
with (
|
|
patch("timmy.agent.Agent"),
|
|
patch("timmy.agent.Ollama"),
|
|
patch("timmy.agent.SqliteDb"),
|
|
patch("timmy.mcp_tools.create_gitea_mcp_tools") as mock_gitea_mcp,
|
|
patch("timmy.mcp_tools.create_filesystem_mcp_tools") as mock_fs_mcp,
|
|
patch("timmy.agent._resolve_model_with_fallback", return_value=("llama3.2:3b", False)),
|
|
patch("timmy.agent._check_model_available", return_value=True),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
from timmy.agent import create_timmy
|
|
|
|
create_timmy(skip_mcp=True)
|
|
|
|
# MCP factory functions should never be called
|
|
mock_gitea_mcp.assert_not_called()
|
|
mock_fs_mcp.assert_not_called()
|
|
|
|
|
|
def test_create_timmy_default_includes_mcp_tools():
|
|
"""create_timmy() without skip_mcp should attempt MCP tool creation."""
|
|
with (
|
|
patch("timmy.agent.Agent"),
|
|
patch("timmy.agent.Ollama"),
|
|
patch("timmy.agent.SqliteDb"),
|
|
patch("timmy.mcp_tools.create_gitea_mcp_tools", return_value=None) as mock_gitea_mcp,
|
|
patch("timmy.mcp_tools.create_filesystem_mcp_tools", return_value=None) as mock_fs_mcp,
|
|
patch("timmy.agent._resolve_model_with_fallback", return_value=("llama3.1", False)),
|
|
patch("timmy.agent._check_model_available", return_value=True),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
from timmy.agent import create_timmy
|
|
|
|
create_timmy(skip_mcp=False)
|
|
|
|
# MCP factories should be called when skip_mcp is False
|
|
mock_gitea_mcp.assert_called_once()
|
|
mock_fs_mcp.assert_called_once()
|
|
|
|
|
|
# ── Configurable fallback chain tests ────────────────────────────────────────
|
|
|
|
|
|
def test_settings_has_fallback_model_lists():
|
|
"""settings.fallback_models and vision_fallback_models exist and are lists."""
|
|
from config import settings
|
|
|
|
assert isinstance(settings.fallback_models, list)
|
|
assert isinstance(settings.vision_fallback_models, list)
|
|
assert len(settings.fallback_models) > 0
|
|
assert len(settings.vision_fallback_models) > 0
|
|
|
|
|
|
def test_resolve_model_uses_configurable_text_fallback():
|
|
"""_resolve_model_with_fallback walks settings.fallback_models for text models."""
|
|
with patch("timmy.agent.settings") as mock_settings:
|
|
mock_settings.ollama_model = "nonexistent-model"
|
|
mock_settings.fallback_models = ["custom-a", "custom-b"]
|
|
mock_settings.vision_fallback_models = ["vision-a"]
|
|
|
|
# First model in chain is available
|
|
with patch("timmy.agent._check_model_available", side_effect=lambda m: m == "custom-a"):
|
|
from timmy.agent import _resolve_model_with_fallback
|
|
|
|
model, is_fallback = _resolve_model_with_fallback(
|
|
requested_model="nonexistent-model",
|
|
require_vision=False,
|
|
auto_pull=False,
|
|
)
|
|
assert model == "custom-a"
|
|
assert is_fallback is True
|
|
|
|
|
|
def test_resolve_model_uses_configurable_vision_fallback():
|
|
"""_resolve_model_with_fallback walks settings.vision_fallback_models for vision."""
|
|
with patch("timmy.agent.settings") as mock_settings:
|
|
mock_settings.ollama_model = "nonexistent-model"
|
|
mock_settings.fallback_models = ["text-a"]
|
|
mock_settings.vision_fallback_models = ["vision-x", "vision-y"]
|
|
|
|
with patch("timmy.agent._check_model_available", side_effect=lambda m: m == "vision-y"):
|
|
from timmy.agent import _resolve_model_with_fallback
|
|
|
|
model, is_fallback = _resolve_model_with_fallback(
|
|
requested_model="nonexistent-model",
|
|
require_vision=True,
|
|
auto_pull=False,
|
|
)
|
|
assert model == "vision-y"
|
|
assert is_fallback is True
|
|
|
|
|
|
def test_get_effective_ollama_model_walks_fallback_chain():
|
|
"""get_effective_ollama_model uses settings.fallback_models."""
|
|
with (
|
|
patch("config.settings") as mock_settings,
|
|
patch("config.check_ollama_model_available", side_effect=lambda m: m == "fb-2") as _,
|
|
):
|
|
mock_settings.ollama_model = "gone-model"
|
|
mock_settings.ollama_url = "http://localhost:11434"
|
|
mock_settings.fallback_models = ["fb-1", "fb-2", "fb-3"]
|
|
|
|
from config import get_effective_ollama_model
|
|
|
|
result = get_effective_ollama_model()
|
|
assert result == "fb-2"
|
|
|
|
|
|
# ── _build_tools_list ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_build_tools_list_empty_when_tools_disabled():
|
|
"""Small models get an empty tools list."""
|
|
from timmy.agent import _build_tools_list
|
|
|
|
result = _build_tools_list(use_tools=False, skip_mcp=False, model_name="llama3.2")
|
|
assert result == []
|
|
|
|
|
|
def test_build_tools_list_includes_toolkit_when_enabled():
|
|
"""Tool-capable models get the full toolkit."""
|
|
mock_toolkit = MagicMock()
|
|
with patch("timmy.agent.create_full_toolkit", return_value=mock_toolkit):
|
|
from timmy.agent import _build_tools_list
|
|
|
|
result = _build_tools_list(use_tools=True, skip_mcp=True, model_name="llama3.1")
|
|
assert mock_toolkit in result
|
|
|
|
|
|
def test_build_tools_list_skips_mcp_when_flagged():
|
|
"""skip_mcp=True must not call MCP factories."""
|
|
mock_toolkit = MagicMock()
|
|
with (
|
|
patch("timmy.agent.create_full_toolkit", return_value=mock_toolkit),
|
|
patch("timmy.mcp_tools.create_gitea_mcp_tools") as mock_gitea,
|
|
patch("timmy.mcp_tools.create_filesystem_mcp_tools") as mock_fs,
|
|
):
|
|
from timmy.agent import _build_tools_list
|
|
|
|
_build_tools_list(use_tools=True, skip_mcp=True, model_name="llama3.1")
|
|
mock_gitea.assert_not_called()
|
|
mock_fs.assert_not_called()
|
|
|
|
|
|
def test_build_tools_list_includes_mcp_when_not_skipped():
|
|
"""skip_mcp=False should attempt MCP tool creation."""
|
|
mock_toolkit = MagicMock()
|
|
with (
|
|
patch("timmy.agent.create_full_toolkit", return_value=mock_toolkit),
|
|
patch("timmy.mcp_tools.create_gitea_mcp_tools", return_value=None) as mock_gitea,
|
|
patch("timmy.mcp_tools.create_filesystem_mcp_tools", return_value=None) as mock_fs,
|
|
):
|
|
from timmy.agent import _build_tools_list
|
|
|
|
_build_tools_list(use_tools=True, skip_mcp=False, model_name="llama3.1")
|
|
mock_gitea.assert_called_once()
|
|
mock_fs.assert_called_once()
|
|
|
|
|
|
# ── _build_prompt ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_build_prompt_includes_base_prompt():
|
|
"""Prompt should always contain the base system prompt."""
|
|
from timmy.agent import _build_prompt
|
|
|
|
result = _build_prompt(use_tools=False, session_id="test")
|
|
assert "Timmy" in result
|
|
|
|
|
|
def test_build_prompt_appends_memory_context():
|
|
"""Memory context should be appended when available."""
|
|
mock_memory = MagicMock()
|
|
mock_memory.get_system_context.return_value = "User prefers dark mode."
|
|
with patch("timmy.memory_system.memory_system", mock_memory):
|
|
from timmy.agent import _build_prompt
|
|
|
|
result = _build_prompt(use_tools=True, session_id="test")
|
|
assert "GROUNDED CONTEXT" in result
|
|
assert "dark mode" in result
|
|
|
|
|
|
def test_build_prompt_truncates_long_memory():
|
|
"""Long memory context should be truncated."""
|
|
mock_memory = MagicMock()
|
|
mock_memory.get_system_context.return_value = "x" * 10000
|
|
with patch("timmy.memory_system.memory_system", mock_memory):
|
|
from timmy.agent import _build_prompt
|
|
|
|
result = _build_prompt(use_tools=False, session_id="test")
|
|
assert "[truncated]" in result
|
|
|
|
|
|
def test_build_prompt_survives_memory_failure():
|
|
"""Prompt should fall back to base when memory fails."""
|
|
mock_memory = MagicMock()
|
|
mock_memory.get_system_context.side_effect = RuntimeError("db locked")
|
|
with patch("timmy.memory_system.memory_system", mock_memory):
|
|
from timmy.agent import _build_prompt
|
|
|
|
result = _build_prompt(use_tools=True, session_id="test")
|
|
assert "Timmy" in result
|
|
# Memory context should NOT be appended (the db locked error was caught)
|
|
assert "db locked" not in result
|
|
|
|
|
|
# ── _create_ollama_agent ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_create_ollama_agent_passes_correct_kwargs():
|
|
"""_create_ollama_agent must pass the expected kwargs to Agent."""
|
|
with (
|
|
patch("timmy.agent.Agent") as MockAgent,
|
|
patch("timmy.agent.Ollama"),
|
|
patch("timmy.agent.SqliteDb"),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
from timmy.agent import _create_ollama_agent
|
|
|
|
_create_ollama_agent(
|
|
db_file="test.db",
|
|
model_name="llama3.1",
|
|
tools_list=[MagicMock()],
|
|
full_prompt="test prompt",
|
|
use_tools=True,
|
|
)
|
|
kwargs = MockAgent.call_args.kwargs
|
|
assert kwargs["description"] == "test prompt"
|
|
assert kwargs["markdown"] is False
|
|
|
|
|
|
def test_create_ollama_agent_none_tools_when_empty():
|
|
"""Empty tools_list should pass tools=None to Agent."""
|
|
with (
|
|
patch("timmy.agent.Agent") as MockAgent,
|
|
patch("timmy.agent.Ollama"),
|
|
patch("timmy.agent.SqliteDb"),
|
|
patch("timmy.agent._warmup_model", return_value=True),
|
|
):
|
|
from timmy.agent import _create_ollama_agent
|
|
|
|
_create_ollama_agent(
|
|
db_file="test.db",
|
|
model_name="llama3.2",
|
|
tools_list=[],
|
|
full_prompt="test prompt",
|
|
use_tools=False,
|
|
)
|
|
kwargs = MockAgent.call_args.kwargs
|
|
assert kwargs["tools"] is None
|
|
|
|
|
|
def test_no_hardcoded_fallback_constants_in_agent():
|
|
"""agent.py must not define module-level DEFAULT_MODEL_FALLBACKS."""
|
|
import timmy.agent as agent_mod
|
|
|
|
assert not hasattr(agent_mod, "DEFAULT_MODEL_FALLBACKS"), (
|
|
"Hardcoded DEFAULT_MODEL_FALLBACKS still exists — use settings.fallback_models"
|
|
)
|
|
assert not hasattr(agent_mod, "VISION_MODEL_FALLBACKS"), (
|
|
"Hardcoded VISION_MODEL_FALLBACKS still exists — use settings.vision_fallback_models"
|
|
)
|