2026-03-19 20:46:20 -04:00
|
|
|
"""Tests for src/timmy/backends.py — backend helpers and classes."""
|
2026-02-21 16:53:16 +00:00
|
|
|
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
|
|
|
|
# ── is_apple_silicon ──────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-08 12:50:44 -04:00
|
|
|
|
2026-02-21 16:53:16 +00:00
|
|
|
def test_is_apple_silicon_true_on_arm_darwin():
|
ruff (#169)
* polish: streamline nav, extract inline styles, improve tablet UX
- Restructure desktop nav from 8+ flat links + overflow dropdown into
5 grouped dropdowns (Core, Agents, Intel, System, More) matching
the mobile menu structure to reduce decision fatigue
- Extract all inline styles from mission_control.html and base.html
notification elements into mission-control.css with semantic classes
- Replace JS-built innerHTML with secure DOM construction in
notification loader and chat history
- Add CONNECTING state to connection indicator (amber) instead of
showing OFFLINE before WebSocket connects
- Add tablet breakpoint (1024px) with larger touch targets for
Apple Pencil / stylus use and safe-area padding for iPad toolbar
- Add active-link highlighting in desktop dropdown menus
- Rename "Mission Control" page title to "System Overview" to
disambiguate from the chat home page
- Add "Home — Timmy Time" page title to index.html
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* fix(security): move auth-gate credentials to environment variables
Hardcoded username, password, and HMAC secret in auth-gate.py replaced
with os.environ lookups. Startup now refuses to run if any variable is
unset. Added AUTH_GATE_SECRET/USER/PASS to .env.example.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* refactor(tooling): migrate from black+isort+bandit to ruff
Replace three separate linting/formatting tools with a single ruff
invocation. Updates tox.ini (lint, format, pre-push, pre-commit envs),
.pre-commit-config.yaml, and CI workflow. Fixes all ruff errors
including unused imports, missing raise-from, and undefined names.
Ruff config maps existing bandit skips to equivalent S-rules.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 12:23:35 -04:00
|
|
|
with (
|
|
|
|
|
patch("timmy.backends.platform.system", return_value="Darwin"),
|
|
|
|
|
patch("timmy.backends.platform.machine", return_value="arm64"),
|
2026-03-08 12:50:44 -04:00
|
|
|
):
|
2026-02-21 16:53:16 +00:00
|
|
|
from timmy.backends import is_apple_silicon
|
2026-03-08 12:50:44 -04:00
|
|
|
|
2026-02-21 16:53:16 +00:00
|
|
|
assert is_apple_silicon() is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_apple_silicon_false_on_linux():
|
ruff (#169)
* polish: streamline nav, extract inline styles, improve tablet UX
- Restructure desktop nav from 8+ flat links + overflow dropdown into
5 grouped dropdowns (Core, Agents, Intel, System, More) matching
the mobile menu structure to reduce decision fatigue
- Extract all inline styles from mission_control.html and base.html
notification elements into mission-control.css with semantic classes
- Replace JS-built innerHTML with secure DOM construction in
notification loader and chat history
- Add CONNECTING state to connection indicator (amber) instead of
showing OFFLINE before WebSocket connects
- Add tablet breakpoint (1024px) with larger touch targets for
Apple Pencil / stylus use and safe-area padding for iPad toolbar
- Add active-link highlighting in desktop dropdown menus
- Rename "Mission Control" page title to "System Overview" to
disambiguate from the chat home page
- Add "Home — Timmy Time" page title to index.html
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* fix(security): move auth-gate credentials to environment variables
Hardcoded username, password, and HMAC secret in auth-gate.py replaced
with os.environ lookups. Startup now refuses to run if any variable is
unset. Added AUTH_GATE_SECRET/USER/PASS to .env.example.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* refactor(tooling): migrate from black+isort+bandit to ruff
Replace three separate linting/formatting tools with a single ruff
invocation. Updates tox.ini (lint, format, pre-push, pre-commit envs),
.pre-commit-config.yaml, and CI workflow. Fixes all ruff errors
including unused imports, missing raise-from, and undefined names.
Ruff config maps existing bandit skips to equivalent S-rules.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 12:23:35 -04:00
|
|
|
with (
|
|
|
|
|
patch("timmy.backends.platform.system", return_value="Linux"),
|
|
|
|
|
patch("timmy.backends.platform.machine", return_value="x86_64"),
|
2026-03-08 12:50:44 -04:00
|
|
|
):
|
2026-02-21 16:53:16 +00:00
|
|
|
from timmy.backends import is_apple_silicon
|
2026-03-08 12:50:44 -04:00
|
|
|
|
2026-02-21 16:53:16 +00:00
|
|
|
assert is_apple_silicon() is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_apple_silicon_false_on_intel_mac():
|
ruff (#169)
* polish: streamline nav, extract inline styles, improve tablet UX
- Restructure desktop nav from 8+ flat links + overflow dropdown into
5 grouped dropdowns (Core, Agents, Intel, System, More) matching
the mobile menu structure to reduce decision fatigue
- Extract all inline styles from mission_control.html and base.html
notification elements into mission-control.css with semantic classes
- Replace JS-built innerHTML with secure DOM construction in
notification loader and chat history
- Add CONNECTING state to connection indicator (amber) instead of
showing OFFLINE before WebSocket connects
- Add tablet breakpoint (1024px) with larger touch targets for
Apple Pencil / stylus use and safe-area padding for iPad toolbar
- Add active-link highlighting in desktop dropdown menus
- Rename "Mission Control" page title to "System Overview" to
disambiguate from the chat home page
- Add "Home — Timmy Time" page title to index.html
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* fix(security): move auth-gate credentials to environment variables
Hardcoded username, password, and HMAC secret in auth-gate.py replaced
with os.environ lookups. Startup now refuses to run if any variable is
unset. Added AUTH_GATE_SECRET/USER/PASS to .env.example.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* refactor(tooling): migrate from black+isort+bandit to ruff
Replace three separate linting/formatting tools with a single ruff
invocation. Updates tox.ini (lint, format, pre-push, pre-commit envs),
.pre-commit-config.yaml, and CI workflow. Fixes all ruff errors
including unused imports, missing raise-from, and undefined names.
Ruff config maps existing bandit skips to equivalent S-rules.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 12:23:35 -04:00
|
|
|
with (
|
|
|
|
|
patch("timmy.backends.platform.system", return_value="Darwin"),
|
|
|
|
|
patch("timmy.backends.platform.machine", return_value="x86_64"),
|
2026-03-08 12:50:44 -04:00
|
|
|
):
|
2026-02-21 16:53:16 +00:00
|
|
|
from timmy.backends import is_apple_silicon
|
2026-03-08 12:50:44 -04:00
|
|
|
|
2026-02-21 16:53:16 +00:00
|
|
|
assert is_apple_silicon() is False
|
|
|
|
|
|
|
|
|
|
|
2026-02-28 09:35:44 -05:00
|
|
|
# ── ClaudeBackend ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_available_false_when_no_key():
|
|
|
|
|
"""claude_available() returns False when ANTHROPIC_API_KEY is empty."""
|
|
|
|
|
with patch("config.settings") as mock_settings:
|
|
|
|
|
mock_settings.anthropic_api_key = ""
|
|
|
|
|
from timmy.backends import claude_available
|
2026-03-08 12:50:44 -04:00
|
|
|
|
2026-02-28 09:35:44 -05:00
|
|
|
assert claude_available() is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_available_true_when_key_set():
|
|
|
|
|
"""claude_available() returns True when ANTHROPIC_API_KEY is set."""
|
|
|
|
|
with patch("config.settings") as mock_settings:
|
|
|
|
|
mock_settings.anthropic_api_key = "sk-ant-test-key"
|
|
|
|
|
from timmy.backends import claude_available
|
2026-03-08 12:50:44 -04:00
|
|
|
|
2026-02-28 09:35:44 -05:00
|
|
|
assert claude_available() is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_backend_init_with_explicit_params():
|
|
|
|
|
"""ClaudeBackend can be created with explicit api_key and model."""
|
|
|
|
|
from timmy.backends import ClaudeBackend
|
2026-03-08 12:50:44 -04:00
|
|
|
|
2026-02-28 09:35:44 -05:00
|
|
|
backend = ClaudeBackend(api_key="sk-ant-test", model="haiku")
|
|
|
|
|
assert backend._api_key == "sk-ant-test"
|
|
|
|
|
assert "haiku" in backend._model
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_backend_init_resolves_short_names():
|
|
|
|
|
"""ClaudeBackend resolves short model names to full IDs."""
|
2026-03-08 12:50:44 -04:00
|
|
|
from timmy.backends import CLAUDE_MODELS, ClaudeBackend
|
|
|
|
|
|
2026-02-28 09:35:44 -05:00
|
|
|
backend = ClaudeBackend(api_key="sk-test", model="sonnet")
|
|
|
|
|
assert backend._model == CLAUDE_MODELS["sonnet"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_backend_init_passes_through_full_model_id():
|
|
|
|
|
"""ClaudeBackend passes through full model IDs unchanged."""
|
|
|
|
|
from timmy.backends import ClaudeBackend
|
2026-03-08 12:50:44 -04:00
|
|
|
|
2026-02-28 09:35:44 -05:00
|
|
|
backend = ClaudeBackend(api_key="sk-test", model="claude-haiku-4-5-20251001")
|
|
|
|
|
assert backend._model == "claude-haiku-4-5-20251001"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_backend_run_no_key_returns_error():
|
|
|
|
|
"""run() gracefully returns error message when no API key."""
|
|
|
|
|
from timmy.backends import ClaudeBackend
|
2026-03-08 12:50:44 -04:00
|
|
|
|
2026-02-28 09:35:44 -05:00
|
|
|
backend = ClaudeBackend(api_key="", model="haiku")
|
|
|
|
|
result = backend.run("hello")
|
|
|
|
|
assert "not configured" in result.content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_backend_run_success():
|
|
|
|
|
"""run() returns content from the Anthropic API on success."""
|
|
|
|
|
from timmy.backends import ClaudeBackend
|
|
|
|
|
|
|
|
|
|
backend = ClaudeBackend(api_key="sk-ant-test", model="haiku")
|
|
|
|
|
|
|
|
|
|
mock_content = MagicMock()
|
|
|
|
|
mock_content.text = "Sir, affirmative. I am Timmy."
|
|
|
|
|
|
|
|
|
|
mock_response = MagicMock()
|
|
|
|
|
mock_response.content = [mock_content]
|
|
|
|
|
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
mock_client.messages.create.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
with patch.object(backend, "_get_client", return_value=mock_client):
|
|
|
|
|
result = backend.run("Who are you?")
|
|
|
|
|
|
|
|
|
|
assert "Timmy" in result.content
|
|
|
|
|
assert len(backend._history) == 2 # user + assistant
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_backend_run_handles_api_error():
|
|
|
|
|
"""run() returns a graceful error when the API raises."""
|
|
|
|
|
from timmy.backends import ClaudeBackend
|
|
|
|
|
|
|
|
|
|
backend = ClaudeBackend(api_key="sk-ant-test", model="haiku")
|
|
|
|
|
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
mock_client.messages.create.side_effect = ConnectionError("network down")
|
|
|
|
|
|
|
|
|
|
with patch.object(backend, "_get_client", return_value=mock_client):
|
|
|
|
|
result = backend.run("hello")
|
|
|
|
|
|
|
|
|
|
assert "unavailable" in result.content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_backend_history_rolling_window():
|
|
|
|
|
"""History should be capped at 20 entries (10 exchanges)."""
|
|
|
|
|
from timmy.backends import ClaudeBackend
|
|
|
|
|
|
|
|
|
|
backend = ClaudeBackend(api_key="sk-ant-test", model="haiku")
|
|
|
|
|
|
|
|
|
|
mock_content = MagicMock()
|
|
|
|
|
mock_content.text = "OK."
|
|
|
|
|
mock_response = MagicMock()
|
|
|
|
|
mock_response.content = [mock_content]
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
mock_client.messages.create.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
with patch.object(backend, "_get_client", return_value=mock_client):
|
|
|
|
|
for i in range(15):
|
|
|
|
|
backend.run(f"message {i}")
|
|
|
|
|
|
|
|
|
|
assert len(backend._history) <= 20
|
[loop-cycle-40] fix: use get_system_prompt() in cloud backends (#135) (#138)
## What
Cloud backends (Grok, Claude, AirLLM) were importing SYSTEM_PROMPT directly, which is always SYSTEM_PROMPT_LITE and contains unformatted {model_name} and {session_id} placeholders.
## Changes
- backends.py: Replace `from timmy.prompts import SYSTEM_PROMPT` with `from timmy.prompts import get_system_prompt`
- AirLLM: uses `get_system_prompt(tools_enabled=False, session_id="airllm")` (LITE tier, correct)
- Grok: uses `get_system_prompt(tools_enabled=True, session_id="grok")` (FULL tier)
- Claude: uses `get_system_prompt(tools_enabled=True, session_id="claude")` (FULL tier)
- 9 new tests verify formatted model names, correct tier selection, and session_id formatting
## Tests
1508 passed, 0 failed (41 new tests this cycle)
Fixes #135
Co-authored-by: Kimi Agent <kimi@timmy.local>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/138
Reviewed-by: rockachopa <alexpaynex@gmail.com>
Co-authored-by: hermes <hermes@timmy.local>
Co-committed-by: hermes <hermes@timmy.local>
2026-03-15 09:44:43 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── ClaudeBackend prompt formatting ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_prompt_contains_formatted_model_name():
|
|
|
|
|
"""Claude system prompt should have actual model name, not literal {model_name}."""
|
|
|
|
|
with patch("config.settings") as mock_settings:
|
|
|
|
|
mock_settings.ollama_model = "llama3.2:3b"
|
|
|
|
|
from timmy.backends import ClaudeBackend
|
|
|
|
|
|
|
|
|
|
backend = ClaudeBackend(api_key="sk-ant-test", model="haiku")
|
|
|
|
|
|
|
|
|
|
# Mock the client to capture the system parameter
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
mock_content = MagicMock()
|
|
|
|
|
mock_content.text = "test response"
|
|
|
|
|
mock_response = MagicMock()
|
|
|
|
|
mock_response.content = [mock_content]
|
|
|
|
|
mock_client.messages.create.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
with patch.object(backend, "_get_client", return_value=mock_client):
|
|
|
|
|
backend.run("test message")
|
|
|
|
|
|
|
|
|
|
# Get the system parameter from the create call
|
|
|
|
|
call_kwargs = mock_client.messages.create.call_args[1]
|
|
|
|
|
system_prompt = call_kwargs.get("system", "")
|
|
|
|
|
|
|
|
|
|
# Should contain the actual model name, not the placeholder
|
|
|
|
|
assert "{model_name}" not in system_prompt
|
|
|
|
|
assert "llama3.2:3b" in system_prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_prompt_gets_full_tier():
|
|
|
|
|
"""Claude should get FULL tier prompt (tools_enabled=True)."""
|
|
|
|
|
with patch("config.settings") as mock_settings:
|
|
|
|
|
mock_settings.ollama_model = "test-model"
|
|
|
|
|
from timmy.backends import ClaudeBackend
|
|
|
|
|
|
|
|
|
|
backend = ClaudeBackend(api_key="sk-ant-test", model="haiku")
|
|
|
|
|
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
mock_content = MagicMock()
|
|
|
|
|
mock_content.text = "test response"
|
|
|
|
|
mock_response = MagicMock()
|
|
|
|
|
mock_response.content = [mock_content]
|
|
|
|
|
mock_client.messages.create.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
with patch.object(backend, "_get_client", return_value=mock_client):
|
|
|
|
|
backend.run("test message")
|
|
|
|
|
|
|
|
|
|
call_kwargs = mock_client.messages.create.call_args[1]
|
|
|
|
|
system_prompt = call_kwargs.get("system", "")
|
|
|
|
|
|
|
|
|
|
# FULL tier should have TOOL USAGE section
|
|
|
|
|
assert "TOOL USAGE" in system_prompt
|
|
|
|
|
# FULL tier should have the full voice and brevity section
|
|
|
|
|
assert "VOICE AND BREVITY" in system_prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_prompt_contains_session_id():
|
|
|
|
|
"""Claude prompt should have session_id formatted, not placeholder."""
|
|
|
|
|
with patch("config.settings") as mock_settings:
|
|
|
|
|
mock_settings.ollama_model = "test-model"
|
|
|
|
|
from timmy.backends import ClaudeBackend
|
|
|
|
|
|
|
|
|
|
backend = ClaudeBackend(api_key="sk-ant-test", model="haiku")
|
|
|
|
|
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
mock_content = MagicMock()
|
|
|
|
|
mock_content.text = "test response"
|
|
|
|
|
mock_response = MagicMock()
|
|
|
|
|
mock_response.content = [mock_content]
|
|
|
|
|
mock_client.messages.create.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
with patch.object(backend, "_get_client", return_value=mock_client):
|
|
|
|
|
backend.run("test message")
|
|
|
|
|
|
|
|
|
|
call_kwargs = mock_client.messages.create.call_args[1]
|
|
|
|
|
system_prompt = call_kwargs.get("system", "")
|
|
|
|
|
|
|
|
|
|
# Should contain the session_id, not the placeholder
|
|
|
|
|
assert '{session_id}"' not in system_prompt
|
|
|
|
|
assert 'session "claude"' in system_prompt
|