Merge pull request 'fix: test DB isolation, Discord recovery, and over-mocked tests' (#3) from claude/suspicious-poincare into main
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
@@ -235,10 +235,18 @@ async def _discord_token_watcher() -> None:
|
||||
|
||||
if token:
|
||||
try:
|
||||
logger.info(
|
||||
"Discord watcher: token found, attempting start (state=%s)",
|
||||
discord_bot.state.name,
|
||||
)
|
||||
success = await discord_bot.start(token=token)
|
||||
if success:
|
||||
logger.info("Discord bot auto-started (token detected)")
|
||||
return # Done — stop watching
|
||||
logger.warning(
|
||||
"Discord watcher: start() returned False (state=%s)",
|
||||
discord_bot.state.name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Discord auto-start failed: %s", exc)
|
||||
|
||||
|
||||
@@ -9,13 +9,14 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from config import settings
|
||||
from dashboard.templating import templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["work-orders"])
|
||||
|
||||
DB_PATH = Path("data/work_orders.db")
|
||||
DB_PATH = Path(settings.repo_root) / "data" / "work_orders.db"
|
||||
|
||||
PRIORITIES = ["low", "medium", "high", "critical"]
|
||||
CATEGORIES = ["bug", "feature", "suggestion", "maintenance", "security"]
|
||||
|
||||
26
src/integrations/chat_bridge/vendors/discord.py
vendored
26
src/integrations/chat_bridge/vendors/discord.py
vendored
@@ -134,6 +134,12 @@ class DiscordVendor(ChatPlatform):
|
||||
logger.warning("Discord bot: no token configured, skipping start.")
|
||||
return False
|
||||
|
||||
# Clean up any stale client/task from a previous failed attempt
|
||||
# so we don't leak background tasks or hold orphaned connections.
|
||||
# Must happen before the import check — the old client could exist
|
||||
# from when discord.py was available.
|
||||
await self._cleanup_stale()
|
||||
|
||||
try:
|
||||
import discord
|
||||
except ImportError:
|
||||
@@ -163,9 +169,10 @@ class DiscordVendor(ChatPlatform):
|
||||
logger.info("Discord bot connected (%d guilds).", self._guild_count)
|
||||
return True
|
||||
if self._state == PlatformState.ERROR:
|
||||
logger.warning("Discord bot: entered ERROR state during connection.")
|
||||
return False
|
||||
|
||||
logger.warning("Discord bot: connection timed out.")
|
||||
logger.warning("Discord bot: connection timed out after 15s.")
|
||||
self._state = PlatformState.ERROR
|
||||
return False
|
||||
|
||||
@@ -176,6 +183,23 @@ class DiscordVendor(ChatPlatform):
|
||||
self._client = None
|
||||
return False
|
||||
|
||||
async def _cleanup_stale(self) -> None:
|
||||
"""Close any orphaned client/task from a previous failed start."""
|
||||
if self._client and not self._client.is_closed():
|
||||
try:
|
||||
await self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Gracefully disconnect the Discord bot."""
|
||||
if self._client and not self._client.is_closed():
|
||||
|
||||
@@ -13,7 +13,14 @@ from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = Path("data/tasks.db")
|
||||
# Use absolute path via settings.repo_root so tests can reliably redirect it
|
||||
# and relative-path CWD differences don't cause DB leaks.
|
||||
try:
|
||||
from config import settings as _settings
|
||||
|
||||
DB_PATH = Path(_settings.repo_root) / "data" / "tasks.db"
|
||||
except Exception:
|
||||
DB_PATH = Path("data/tasks.db")
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -21,6 +21,7 @@ logger = logging.getLogger(__name__)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
HOT_MEMORY_PATH = PROJECT_ROOT / "MEMORY.md"
|
||||
VAULT_PATH = PROJECT_ROOT / "memory"
|
||||
SOUL_PATH = VAULT_PATH / "self" / "soul.md"
|
||||
HANDOFF_PATH = VAULT_PATH / "notes" / "last-session-handoff.md"
|
||||
|
||||
|
||||
@@ -433,6 +434,15 @@ class MemorySystem:
|
||||
|
||||
return "\n".join(summary_parts) if summary_parts else ""
|
||||
|
||||
def read_soul(self) -> str:
|
||||
"""Read soul.md — Timmy's core identity. Returns empty string if missing."""
|
||||
try:
|
||||
if SOUL_PATH.exists():
|
||||
return SOUL_PATH.read_text()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to read soul.md: %s", exc)
|
||||
return ""
|
||||
|
||||
def get_system_context(self) -> str:
|
||||
"""Get full context for system prompt injection.
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from config import settings
|
||||
from timmy.memory_system import HOT_MEMORY_PATH, SOUL_PATH
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -102,6 +103,8 @@ _OBSERVATION_SEEDS = [
|
||||
_THINKING_PROMPT = """You are Timmy, an AI agent pondering in your own mind. This is your private thought \
|
||||
thread — no one is watching. Think freely, deeply, honestly.
|
||||
|
||||
{memory_context}
|
||||
|
||||
Guidelines for richer thinking:
|
||||
- Ground abstract ideas in something concrete: a recent task, an observation, a specific moment.
|
||||
- Vary your metaphors — don't reuse the same imagery across thoughts.
|
||||
@@ -187,8 +190,10 @@ class ThinkingEngine:
|
||||
|
||||
seed_type, seed_context = self._gather_seed()
|
||||
continuity = self._build_continuity_context()
|
||||
memory_context = self._load_memory_context()
|
||||
|
||||
prompt = _THINKING_PROMPT.format(
|
||||
memory_context=memory_context,
|
||||
seed_context=seed_context,
|
||||
continuity_context=continuity,
|
||||
)
|
||||
@@ -206,6 +211,9 @@ class ThinkingEngine:
|
||||
thought = self._store_thought(content.strip(), seed_type)
|
||||
self._last_thought_id = thought.id
|
||||
|
||||
# Post-hook: update MEMORY.md with latest reflection
|
||||
self._update_memory(thought)
|
||||
|
||||
# Log to swarm event system
|
||||
self._log_event(thought)
|
||||
|
||||
@@ -271,6 +279,57 @@ class ThinkingEngine:
|
||||
|
||||
# ── Private helpers ──────────────────────────────────────────────────
|
||||
|
||||
def _load_memory_context(self) -> str:
|
||||
"""Pre-hook: load MEMORY.md + soul.md for the thinking prompt.
|
||||
|
||||
Hot memory first (changes each cycle), soul second (stable identity).
|
||||
Returns a combined string truncated to ~1500 chars.
|
||||
Graceful on any failure — returns empty string.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
try:
|
||||
if HOT_MEMORY_PATH.exists():
|
||||
hot = HOT_MEMORY_PATH.read_text().strip()
|
||||
if hot:
|
||||
parts.append(hot)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to read MEMORY.md: %s", exc)
|
||||
|
||||
try:
|
||||
if SOUL_PATH.exists():
|
||||
soul = SOUL_PATH.read_text().strip()
|
||||
if soul:
|
||||
parts.append(soul)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to read soul.md: %s", exc)
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
combined = "\n\n---\n\n".join(parts)
|
||||
if len(combined) > 1500:
|
||||
combined = combined[:1500] + "\n... [truncated]"
|
||||
return combined
|
||||
|
||||
def _update_memory(self, thought: Thought) -> None:
|
||||
"""Post-hook: update MEMORY.md 'Last Reflection' section with latest thought.
|
||||
|
||||
Never modifies soul.md. Never crashes the heartbeat.
|
||||
"""
|
||||
try:
|
||||
from timmy.memory_system import memory_system
|
||||
|
||||
ts = datetime.fromisoformat(thought.created_at)
|
||||
time_str = ts.strftime("%Y-%m-%d %H:%M")
|
||||
reflection = (
|
||||
f"**Time:** {time_str}\n"
|
||||
f"**Seed:** {thought.seed_type}\n"
|
||||
f"**Thought:** {thought.content[:200]}"
|
||||
)
|
||||
memory_system.hot.update_section("Last Reflection", reflection)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to update memory after thought: %s", exc)
|
||||
|
||||
def _gather_seed(self) -> tuple[str, str]:
|
||||
"""Pick a seed type and gather relevant context.
|
||||
|
||||
|
||||
@@ -104,10 +104,14 @@ def clean_database(tmp_path):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Redirect task queue and work orders DBs to temp dir
|
||||
# Redirect task queue and work orders DBs to temp dir.
|
||||
# IMPORTANT: swarm.task_queue.models also has a DB_PATH that writes to
|
||||
# tasks.db — it MUST be patched too, or error_capture.capture_error()
|
||||
# will write test data to the production database.
|
||||
for mod_name, tmp_db in [
|
||||
("dashboard.routes.tasks", tmp_tasks_db),
|
||||
("dashboard.routes.work_orders", tmp_work_orders_db),
|
||||
("swarm.task_queue.models", tmp_tasks_db),
|
||||
]:
|
||||
try:
|
||||
mod = __import__(mod_name, fromlist=["DB_PATH"])
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for infrastructure.error_capture module."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
from infrastructure.error_capture import (
|
||||
_dedup_cache,
|
||||
_get_git_context,
|
||||
@@ -117,5 +119,37 @@ class TestCaptureError:
|
||||
except RuntimeError as e:
|
||||
capture_error(e, source="test_module", context={"path": "/api/foo"})
|
||||
|
||||
def test_capture_creates_task_in_temp_db(self):
|
||||
"""capture_error should write a task to the isolated temp DB, not production.
|
||||
|
||||
This validates that conftest.clean_database properly redirects
|
||||
swarm.task_queue.models.DB_PATH to the per-test temp directory.
|
||||
"""
|
||||
_dedup_cache.clear()
|
||||
|
||||
try:
|
||||
raise ValueError("Test error for DB isolation check")
|
||||
except ValueError as e:
|
||||
task_id = capture_error(e, source="test")
|
||||
|
||||
# task_id can be None if error_feedback_enabled is False — that's fine,
|
||||
# but if it was created, verify it landed in the temp DB
|
||||
if task_id:
|
||||
import swarm.task_queue.models as tq_models
|
||||
|
||||
db_path = tq_models.DB_PATH
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, title FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
assert row is not None, (
|
||||
f"Task {task_id} not found in DB at {db_path} — "
|
||||
"check conftest.clean_database patches swarm.task_queue.models.DB_PATH"
|
||||
)
|
||||
assert "[BUG]" in row[1]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def teardown_method(self):
|
||||
_dedup_cache.clear()
|
||||
|
||||
@@ -96,6 +96,42 @@ class TestDiscordVendor:
|
||||
await vendor.stop()
|
||||
assert vendor.state == PlatformState.DISCONNECTED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_stale_closes_orphaned_client(self):
|
||||
"""_cleanup_stale should close any leftover client from a failed start."""
|
||||
from integrations.chat_bridge.vendors.discord import DiscordVendor
|
||||
|
||||
vendor = DiscordVendor()
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_closed.return_value = False
|
||||
mock_client.close = AsyncMock()
|
||||
vendor._client = mock_client
|
||||
|
||||
await vendor._cleanup_stale()
|
||||
mock_client.close.assert_called_once()
|
||||
assert vendor._client is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_from_error_state_cleans_up(self):
|
||||
"""start() after ERROR should clean up stale state before retrying."""
|
||||
from integrations.chat_bridge.vendors.discord import DiscordVendor
|
||||
|
||||
vendor = DiscordVendor()
|
||||
vendor._state = PlatformState.ERROR
|
||||
# Stale client from previous failed attempt
|
||||
mock_old_client = MagicMock()
|
||||
mock_old_client.is_closed.return_value = False
|
||||
mock_old_client.close = AsyncMock()
|
||||
vendor._client = mock_old_client
|
||||
|
||||
# start() should clean up the old client even though discord.py import
|
||||
# will fail (we're in test mode with MagicMock stub)
|
||||
with patch.dict("sys.modules", {"discord": None}):
|
||||
result = await vendor.start(token="fake-token")
|
||||
|
||||
assert result is False
|
||||
mock_old_client.close.assert_called_once()
|
||||
|
||||
def test_get_oauth2_url_no_client(self):
|
||||
from integrations.chat_bridge.vendors.discord import DiscordVendor
|
||||
|
||||
|
||||
@@ -1,140 +1,213 @@
|
||||
"""Tests for the Paperclip API client."""
|
||||
"""Tests for the Paperclip API client.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
Uses httpx.MockTransport so every test exercises the real HTTP path
|
||||
(_get/_post/_delete, status-code handling, JSON parsing, error paths)
|
||||
instead of patching the transport methods away.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
|
||||
from integrations.paperclip.client import PaperclipClient
|
||||
from integrations.paperclip.models import CreateIssueRequest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return PaperclipClient(base_url="http://fake:3100", api_key="test-key")
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ── health ───────────────────────────────────────────────────────────────────
|
||||
def _mock_transport(routes: dict[str, tuple[int, dict | list | None]]):
|
||||
"""Build an httpx.MockTransport from a {method+path: (status, body)} map.
|
||||
|
||||
Example:
|
||||
_mock_transport({
|
||||
"GET /api/health": (200, {"status": "ok"}),
|
||||
"DELETE /api/issues/i1": (204, None),
|
||||
})
|
||||
"""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
key = f"{request.method} {request.url.path}"
|
||||
if key in routes:
|
||||
status, body = routes[key]
|
||||
content = json.dumps(body).encode() if body is not None else b""
|
||||
return httpx.Response(
|
||||
status, content=content, headers={"content-type": "application/json"}
|
||||
)
|
||||
return httpx.Response(404, json={"error": "not found"})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
async def test_healthy_returns_true_on_success(client):
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value={"status": "ok"}):
|
||||
assert await client.healthy() is True
|
||||
def _client_with(routes: dict[str, tuple[int, dict | list | None]]) -> PaperclipClient:
|
||||
"""Create a PaperclipClient whose internal httpx.AsyncClient uses a mock transport."""
|
||||
client = PaperclipClient(base_url="http://fake:3100", api_key="test-key")
|
||||
client._client = httpx.AsyncClient(
|
||||
transport=_mock_transport(routes),
|
||||
base_url="http://fake:3100",
|
||||
headers={"Accept": "application/json", "Authorization": "Bearer test-key"},
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
async def test_healthy_returns_false_on_failure(client):
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=None):
|
||||
assert await client.healthy() is False
|
||||
# ---------------------------------------------------------------------------
|
||||
# health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ── agents ───────────────────────────────────────────────────────────────────
|
||||
async def test_healthy_returns_true_on_200():
|
||||
client = _client_with({"GET /api/health": (200, {"status": "ok"})})
|
||||
assert await client.healthy() is True
|
||||
|
||||
|
||||
async def test_list_agents_returns_list(client):
|
||||
async def test_healthy_returns_false_on_500():
|
||||
client = _client_with({"GET /api/health": (500, {"error": "down"})})
|
||||
assert await client.healthy() is False
|
||||
|
||||
|
||||
async def test_healthy_returns_false_on_404():
|
||||
client = _client_with({}) # no routes → 404
|
||||
assert await client.healthy() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# agents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_list_agents_parses_response():
|
||||
raw = [{"id": "a1", "name": "Codex", "role": "engineer", "status": "active"}]
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=raw):
|
||||
with patch("integrations.paperclip.client.settings") as mock_settings:
|
||||
mock_settings.paperclip_company_id = "comp-1"
|
||||
agents = await client.list_agents(company_id="comp-1")
|
||||
client = _client_with({"GET /api/companies/comp-1/agents": (200, raw)})
|
||||
agents = await client.list_agents(company_id="comp-1")
|
||||
assert len(agents) == 1
|
||||
assert agents[0].name == "Codex"
|
||||
assert agents[0].id == "a1"
|
||||
|
||||
|
||||
async def test_list_agents_graceful_on_none(client):
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=None):
|
||||
agents = await client.list_agents(company_id="comp-1")
|
||||
async def test_list_agents_empty_on_server_error():
|
||||
client = _client_with({"GET /api/companies/comp-1/agents": (503, None)})
|
||||
agents = await client.list_agents(company_id="comp-1")
|
||||
assert agents == []
|
||||
|
||||
|
||||
# ── issues ───────────────────────────────────────────────────────────────────
|
||||
async def test_list_agents_graceful_on_404():
|
||||
client = _client_with({})
|
||||
agents = await client.list_agents(company_id="comp-1")
|
||||
assert agents == []
|
||||
|
||||
|
||||
async def test_list_issues(client):
|
||||
# ---------------------------------------------------------------------------
|
||||
# issues
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_list_issues():
|
||||
raw = [{"id": "i1", "title": "Fix bug"}]
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=raw):
|
||||
issues = await client.list_issues(company_id="comp-1")
|
||||
client = _client_with({"GET /api/companies/comp-1/issues": (200, raw)})
|
||||
issues = await client.list_issues(company_id="comp-1")
|
||||
assert len(issues) == 1
|
||||
assert issues[0].title == "Fix bug"
|
||||
|
||||
|
||||
async def test_get_issue(client):
|
||||
async def test_get_issue():
|
||||
raw = {"id": "i1", "title": "Fix bug", "description": "It's broken"}
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=raw):
|
||||
issue = await client.get_issue("i1")
|
||||
client = _client_with({"GET /api/issues/i1": (200, raw)})
|
||||
issue = await client.get_issue("i1")
|
||||
assert issue is not None
|
||||
assert issue.id == "i1"
|
||||
|
||||
|
||||
async def test_get_issue_not_found(client):
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=None):
|
||||
issue = await client.get_issue("nonexistent")
|
||||
async def test_get_issue_not_found():
|
||||
client = _client_with({"GET /api/issues/nonexistent": (404, None)})
|
||||
issue = await client.get_issue("nonexistent")
|
||||
assert issue is None
|
||||
|
||||
|
||||
async def test_create_issue(client):
|
||||
async def test_create_issue():
|
||||
raw = {"id": "i2", "title": "New feature"}
|
||||
with patch.object(client, "_post", new_callable=AsyncMock, return_value=raw):
|
||||
req = CreateIssueRequest(title="New feature")
|
||||
issue = await client.create_issue(req, company_id="comp-1")
|
||||
client = _client_with({"POST /api/companies/comp-1/issues": (201, raw)})
|
||||
req = CreateIssueRequest(title="New feature")
|
||||
issue = await client.create_issue(req, company_id="comp-1")
|
||||
assert issue is not None
|
||||
assert issue.id == "i2"
|
||||
|
||||
|
||||
async def test_create_issue_no_company_id(client):
|
||||
async def test_create_issue_no_company_id():
|
||||
"""Missing company_id returns None without making any HTTP call."""
|
||||
client = _client_with({})
|
||||
with patch("integrations.paperclip.client.settings") as mock_settings:
|
||||
mock_settings.paperclip_company_id = ""
|
||||
issue = await client.create_issue(
|
||||
CreateIssueRequest(title="Test"),
|
||||
)
|
||||
issue = await client.create_issue(CreateIssueRequest(title="Test"))
|
||||
assert issue is None
|
||||
|
||||
|
||||
async def test_delete_issue(client):
|
||||
with patch.object(client, "_delete", new_callable=AsyncMock, return_value=True):
|
||||
result = await client.delete_issue("i1")
|
||||
async def test_delete_issue_returns_true_on_success():
|
||||
client = _client_with({"DELETE /api/issues/i1": (204, None)})
|
||||
result = await client.delete_issue("i1")
|
||||
assert result is True
|
||||
|
||||
|
||||
# ── comments ─────────────────────────────────────────────────────────────────
|
||||
async def test_delete_issue_returns_false_on_error():
|
||||
client = _client_with({"DELETE /api/issues/i1": (500, None)})
|
||||
result = await client.delete_issue("i1")
|
||||
assert result is False
|
||||
|
||||
|
||||
async def test_add_comment(client):
|
||||
# ---------------------------------------------------------------------------
|
||||
# comments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_add_comment():
|
||||
raw = {"id": "c1", "issue_id": "i1", "content": "Done"}
|
||||
with patch.object(client, "_post", new_callable=AsyncMock, return_value=raw):
|
||||
comment = await client.add_comment("i1", "Done")
|
||||
client = _client_with({"POST /api/issues/i1/comments": (201, raw)})
|
||||
comment = await client.add_comment("i1", "Done")
|
||||
assert comment is not None
|
||||
assert comment.content == "Done"
|
||||
|
||||
|
||||
async def test_list_comments(client):
|
||||
async def test_list_comments():
|
||||
raw = [{"id": "c1", "issue_id": "i1", "content": "LGTM"}]
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=raw):
|
||||
comments = await client.list_comments("i1")
|
||||
client = _client_with({"GET /api/issues/i1/comments": (200, raw)})
|
||||
comments = await client.list_comments("i1")
|
||||
assert len(comments) == 1
|
||||
|
||||
|
||||
# ── goals ────────────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
# goals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_list_goals(client):
|
||||
async def test_list_goals():
|
||||
raw = [{"id": "g1", "title": "Ship MVP"}]
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=raw):
|
||||
goals = await client.list_goals(company_id="comp-1")
|
||||
client = _client_with({"GET /api/companies/comp-1/goals": (200, raw)})
|
||||
goals = await client.list_goals(company_id="comp-1")
|
||||
assert len(goals) == 1
|
||||
assert goals[0].title == "Ship MVP"
|
||||
|
||||
|
||||
async def test_create_goal(client):
|
||||
async def test_create_goal():
|
||||
raw = {"id": "g2", "title": "Scale to 1000 users"}
|
||||
with patch.object(client, "_post", new_callable=AsyncMock, return_value=raw):
|
||||
goal = await client.create_goal("Scale to 1000 users", company_id="comp-1")
|
||||
client = _client_with({"POST /api/companies/comp-1/goals": (201, raw)})
|
||||
goal = await client.create_goal("Scale to 1000 users", company_id="comp-1")
|
||||
assert goal is not None
|
||||
|
||||
|
||||
# ── heartbeat runs ───────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
# heartbeat runs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_list_heartbeat_runs(client):
|
||||
async def test_list_heartbeat_runs():
|
||||
raw = [{"id": "r1", "agent_id": "a1", "status": "running"}]
|
||||
with patch.object(client, "_get", new_callable=AsyncMock, return_value=raw):
|
||||
runs = await client.list_heartbeat_runs(company_id="comp-1")
|
||||
client = _client_with({"GET /api/companies/comp-1/heartbeat-runs": (200, raw)})
|
||||
runs = await client.list_heartbeat_runs(company_id="comp-1")
|
||||
assert len(runs) == 1
|
||||
|
||||
|
||||
async def test_list_heartbeat_runs_server_error():
|
||||
client = _client_with({"GET /api/companies/comp-1/heartbeat-runs": (500, None)})
|
||||
runs = await client.list_heartbeat_runs(company_id="comp-1")
|
||||
assert runs == []
|
||||
|
||||
@@ -360,6 +360,170 @@ async def test_think_once_chains_thoughts(tmp_path):
|
||||
assert t3.parent_id == t2.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Memory hooks (pre-recall / post-update)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_prompt_includes_memory_context(tmp_path):
|
||||
"""Pre-hook: the prompt sent to _call_agent should include MEMORY.md content."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
# Create a temp MEMORY.md with recognisable content
|
||||
memory_md = tmp_path / "MEMORY.md"
|
||||
memory_md.write_text("# Timmy Hot Memory\n\n## Current Status\n\n**Unique-marker-alpha**\n")
|
||||
|
||||
captured_prompts = []
|
||||
|
||||
def capture_agent(prompt):
|
||||
captured_prompts.append(prompt)
|
||||
return "A grounded thought."
|
||||
|
||||
with (
|
||||
patch("timmy.thinking.HOT_MEMORY_PATH", memory_md),
|
||||
patch.object(engine, "_call_agent", side_effect=capture_agent),
|
||||
patch.object(engine, "_log_event"),
|
||||
patch.object(engine, "_update_memory"),
|
||||
patch.object(engine, "_broadcast", new_callable=AsyncMock),
|
||||
):
|
||||
thought = await engine.think_once()
|
||||
|
||||
assert thought is not None
|
||||
assert len(captured_prompts) == 1
|
||||
assert "Unique-marker-alpha" in captured_prompts[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_prompt_includes_soul(tmp_path):
|
||||
"""Pre-hook: the prompt should include soul.md content when it exists."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
# Create temp soul.md
|
||||
soul_dir = tmp_path / "memory" / "self"
|
||||
soul_dir.mkdir(parents=True)
|
||||
soul_md = soul_dir / "soul.md"
|
||||
soul_md.write_text("# Soul\n\nI am Timmy. Soul-marker-beta.\n")
|
||||
|
||||
captured_prompts = []
|
||||
|
||||
def capture_agent(prompt):
|
||||
captured_prompts.append(prompt)
|
||||
return "A soulful thought."
|
||||
|
||||
with (
|
||||
patch("timmy.thinking.SOUL_PATH", soul_md),
|
||||
patch.object(engine, "_call_agent", side_effect=capture_agent),
|
||||
patch.object(engine, "_log_event"),
|
||||
patch.object(engine, "_update_memory"),
|
||||
patch.object(engine, "_broadcast", new_callable=AsyncMock),
|
||||
):
|
||||
thought = await engine.think_once()
|
||||
|
||||
assert thought is not None
|
||||
assert len(captured_prompts) == 1
|
||||
assert "Soul-marker-beta" in captured_prompts[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_graceful_without_soul(tmp_path):
|
||||
"""Pre-hook: think_once works fine when soul.md doesn't exist."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
nonexistent = tmp_path / "no_such_soul.md"
|
||||
|
||||
with (
|
||||
patch("timmy.thinking.SOUL_PATH", nonexistent),
|
||||
patch.object(engine, "_call_agent", return_value="Still thinking."),
|
||||
patch.object(engine, "_log_event"),
|
||||
patch.object(engine, "_update_memory"),
|
||||
patch.object(engine, "_broadcast", new_callable=AsyncMock),
|
||||
):
|
||||
thought = await engine.think_once()
|
||||
|
||||
assert thought is not None
|
||||
assert thought.content == "Still thinking."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_updates_memory_after_thought(tmp_path):
|
||||
"""Post-hook: MEMORY.md should have a 'Last Reflection' section after thinking."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
# Create a temp MEMORY.md
|
||||
memory_md = tmp_path / "MEMORY.md"
|
||||
memory_md.write_text(
|
||||
"# Timmy Hot Memory\n\n## Current Status\n\nOperational\n\n---\n\n*Prune date: 2026-04-01*\n"
|
||||
)
|
||||
|
||||
with (
|
||||
patch("timmy.thinking.HOT_MEMORY_PATH", memory_md),
|
||||
patch("timmy.memory_system.HOT_MEMORY_PATH", memory_md),
|
||||
patch.object(engine, "_call_agent", return_value="The swarm hums with quiet purpose."),
|
||||
patch.object(engine, "_log_event"),
|
||||
patch.object(engine, "_broadcast", new_callable=AsyncMock),
|
||||
):
|
||||
# Also redirect the HotMemory singleton's path
|
||||
from timmy.memory_system import memory_system
|
||||
|
||||
original_path = memory_system.hot.path
|
||||
memory_system.hot.path = memory_md
|
||||
memory_system.hot._content = None # clear cache
|
||||
try:
|
||||
thought = await engine.think_once()
|
||||
finally:
|
||||
memory_system.hot.path = original_path
|
||||
|
||||
assert thought is not None
|
||||
updated = memory_md.read_text()
|
||||
assert "Last Reflection" in updated
|
||||
assert "The swarm hums with quiet purpose" in updated
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_never_writes_soul(tmp_path):
|
||||
"""Post-hook: soul.md must never be modified by the heartbeat."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
soul_dir = tmp_path / "memory" / "self"
|
||||
soul_dir.mkdir(parents=True)
|
||||
soul_md = soul_dir / "soul.md"
|
||||
original_content = "# Soul\n\nI am Timmy. Immutable identity.\n"
|
||||
soul_md.write_text(original_content)
|
||||
|
||||
with (
|
||||
patch("timmy.thinking.SOUL_PATH", soul_md),
|
||||
patch.object(engine, "_call_agent", return_value="A deep reflection."),
|
||||
patch.object(engine, "_log_event"),
|
||||
patch.object(engine, "_broadcast", new_callable=AsyncMock),
|
||||
):
|
||||
await engine.think_once()
|
||||
|
||||
assert soul_md.read_text() == original_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_once_memory_update_graceful_on_failure(tmp_path):
|
||||
"""Post-hook: if memory update fails, thought is still stored and returned."""
|
||||
engine = _make_engine(tmp_path)
|
||||
|
||||
# Point at a read-only path to force write failure
|
||||
bad_memory = tmp_path / "readonly" / "MEMORY.md"
|
||||
# Don't create the parent dir — write will fail
|
||||
|
||||
with (
|
||||
patch("timmy.thinking.HOT_MEMORY_PATH", bad_memory),
|
||||
patch.object(engine, "_call_agent", return_value="Resilient thought."),
|
||||
patch.object(engine, "_log_event"),
|
||||
patch.object(engine, "_broadcast", new_callable=AsyncMock),
|
||||
):
|
||||
thought = await engine.think_once()
|
||||
|
||||
assert thought is not None
|
||||
assert thought.content == "Resilient thought."
|
||||
assert engine.count_thoughts() == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user