feat: add default thinking thread — Timmy always ponders (#75)

This commit is contained in:
Alexander Whitestone
2026-02-27 01:00:11 -05:00
committed by GitHub
parent a975a845c5
commit 849b5b1a8d
11 changed files with 1012 additions and 1 deletions

5
.gitignore vendored
View File

@@ -18,8 +18,11 @@ env/
.env.*
!.env.example
# SQLite memory — never commit agent memory
# SQLite — never commit databases or WAL/SHM artifacts
*.db
*.db-shm
*.db-wal
*.db-journal
# Runtime PID files
.watchdog.pid

Binary file not shown.

Binary file not shown.

View File

@@ -135,6 +135,12 @@ class Settings(BaseSettings):
# Fallback to server when browser model is unavailable or too slow.
browser_model_fallback: bool = True
# ── Default Thinking ──────────────────────────────────────────────
# When enabled, Timmy starts an internal thought loop on server start.
# He ponders his existence, recent activity, scripture, and creative ideas.
thinking_enabled: bool = True
thinking_interval_seconds: int = 300 # 5 minutes between thoughts
# ── Scripture / Biblical Integration ──────────────────────────────
# Enable the sovereign biblical text module. When enabled, Timmy
# loads the local ESV text corpus and runs meditation workflows.

View File

@@ -39,6 +39,7 @@ from dashboard.routes.grok import router as grok_router
from dashboard.routes.models import router as models_router
from dashboard.routes.models import api_router as models_api_router
from dashboard.routes.chat_api import router as chat_api_router
from dashboard.routes.thinking import router as thinking_router
from infrastructure.router.api import router as cascade_router
logging.basicConfig(
@@ -80,6 +81,26 @@ async def _briefing_scheduler() -> None:
await asyncio.sleep(_BRIEFING_INTERVAL_HOURS * 3600)
async def _thinking_loop() -> None:
"""Background task: Timmy's default thinking thread.
Starts shortly after server boot and runs on a configurable cadence.
Timmy ponders his existence, recent swarm activity, scripture, creative
ideas, or continues a previous train of thought.
"""
from timmy.thinking import thinking_engine
await asyncio.sleep(10) # Let server finish starting before first thought
while True:
try:
await thinking_engine.think_once()
except Exception as exc:
logger.error("Thinking loop error: %s", exc)
await asyncio.sleep(settings.thinking_interval_seconds)
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(_briefing_scheduler())
@@ -139,6 +160,15 @@ async def lifespan(app: FastAPI):
if spark_engine.enabled:
logger.info("Spark Intelligence active — event capture enabled")
# Start Timmy's default thinking thread (skip in test mode)
thinking_task = None
if settings.thinking_enabled and os.environ.get("TIMMY_TEST_MODE") != "1":
thinking_task = asyncio.create_task(_thinking_loop())
logger.info(
"Default thinking thread started (interval: %ds)",
settings.thinking_interval_seconds,
)
# Auto-start chat integrations (skip silently if unconfigured)
from integrations.telegram_bot.bot import telegram_bot
from integrations.chat_bridge.vendors.discord import discord_bot
@@ -159,6 +189,12 @@ async def lifespan(app: FastAPI):
await discord_bot.stop()
await telegram_bot.stop()
if thinking_task:
thinking_task.cancel()
try:
await thinking_task
except asyncio.CancelledError:
pass
task.cancel()
try:
await task
@@ -223,6 +259,7 @@ app.include_router(grok_router)
app.include_router(models_router)
app.include_router(models_api_router)
app.include_router(chat_api_router)
app.include_router(thinking_router)
app.include_router(cascade_router)

View File

@@ -0,0 +1,65 @@
"""Thinking routes — Timmy's inner thought stream.
GET /thinking — render the thought stream page
GET /thinking/api — JSON list of recent thoughts
GET /thinking/api/{id}/chain — follow a thought chain
"""
import logging
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from timmy.thinking import thinking_engine
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/thinking", tags=["thinking"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@router.get("", response_class=HTMLResponse)
async def thinking_page(request: Request):
"""Render Timmy's thought stream page."""
thoughts = thinking_engine.get_recent_thoughts(limit=50)
return templates.TemplateResponse(
request,
"thinking.html",
{"thoughts": thoughts},
)
@router.get("/api", response_class=JSONResponse)
async def thinking_api(limit: int = 20):
"""Return recent thoughts as JSON."""
thoughts = thinking_engine.get_recent_thoughts(limit=limit)
return [
{
"id": t.id,
"content": t.content,
"seed_type": t.seed_type,
"parent_id": t.parent_id,
"created_at": t.created_at,
}
for t in thoughts
]
@router.get("/api/{thought_id}/chain", response_class=JSONResponse)
async def thought_chain_api(thought_id: str):
"""Follow a thought chain backward and return in chronological order."""
chain = thinking_engine.get_thought_chain(thought_id)
if not chain:
return JSONResponse({"error": "Thought not found"}, status_code=404)
return [
{
"id": t.id,
"content": t.content,
"seed_type": t.seed_type,
"parent_id": t.parent_id,
"created_at": t.created_at,
}
for t in chain
]

View File

@@ -30,6 +30,7 @@
<div class="mc-header-right mc-desktop-nav">
<a href="/tasks" class="mc-test-link">TASKS</a>
<a href="/briefing" class="mc-test-link">BRIEFING</a>
<a href="/thinking" class="mc-test-link" style="color:#c084fc;">THINKING</a>
<a href="/swarm/mission-control" class="mc-test-link">MISSION CONTROL</a>
<a href="/swarm/live" class="mc-test-link">SWARM</a>
<a href="/spark/ui" class="mc-test-link">SPARK</a>

View File

@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block title %}Timmy Time — Thought Stream{% endblock %}
{% block extra_styles %}
<style>
.thinking-container { max-width: 680px; }
.thinking-header {
border-left: 3px solid var(--purple);
padding-left: 1rem;
}
.thinking-title {
font-size: 1.6rem;
font-weight: 700;
color: var(--purple);
letter-spacing: 0.04em;
font-family: var(--font);
}
.thinking-subtitle {
font-size: 0.75rem;
color: var(--text-dim);
margin-top: 0.25rem;
}
.thought-card {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 1rem;
margin-bottom: 0.75rem;
background: rgba(24, 10, 45, 0.5);
transition: border-color 0.2s;
}
.thought-card:hover {
border-color: var(--purple);
}
.thought-content {
font-size: 0.95rem;
line-height: 1.65;
color: var(--text-bright);
white-space: pre-wrap;
word-break: break-word;
}
.thought-meta {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.thought-time {
font-size: 0.72rem;
color: var(--text-dim);
font-family: var(--font);
}
.seed-badge {
font-size: 0.68rem;
padding: 0.15em 0.5em;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.seed-existential { background: rgba(138, 43, 226, 0.2); color: #c084fc; }
.seed-swarm { background: rgba(0, 232, 122, 0.15); color: var(--green); }
.seed-scripture { background: rgba(255, 193, 7, 0.15); color: var(--amber); }
.seed-creative { background: rgba(236, 72, 153, 0.2); color: #f472b6; }
.seed-memory { background: rgba(56, 189, 248, 0.15); color: #38bdf8; }
.seed-freeform { background: rgba(148, 163, 184, 0.15); color: #94a3b8; }
.thought-chain-link {
font-size: 0.72rem;
color: var(--text-dim);
text-decoration: none;
font-family: var(--font);
}
.thought-chain-link:hover { color: var(--purple); }
.no-thoughts {
text-align: center;
color: var(--text-dim);
padding: 3rem 0;
font-size: 0.9rem;
}
@media (max-width: 576px) {
.thinking-title { font-size: 1.3rem; }
.thought-content { font-size: 0.9rem; }
}
</style>
{% endblock %}
{% block content %}
<div class="container thinking-container py-4">
<div class="thinking-header mb-4">
<div class="thinking-title">Thought Stream</div>
<div class="thinking-subtitle">
Timmy's inner monologue &mdash; always thinking, always pondering.
</div>
</div>
<div class="card mc-panel">
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
<span>// INNER THOUGHTS</span>
<span class="badge" style="background:var(--purple-dim); color:var(--purple);">{{ thoughts | length }} thoughts</span>
</div>
<div class="card-body p-3"
id="thought-stream"
hx-get="/thinking/api"
hx-trigger="every 60s"
hx-swap="innerHTML"
hx-select-oob="false">
{% if thoughts %}
{% for thought in thoughts %}
<div class="thought-card">
<div class="thought-meta">
<span class="seed-badge seed-{{ thought.seed_type }}">{{ thought.seed_type }}</span>
<span class="thought-time">{{ thought.created_at[:19] }}</span>
{% if thought.parent_id %}
<a href="/thinking/api/{{ thought.id }}/chain" class="thought-chain-link" title="View thought chain">chain</a>
{% endif %}
</div>
<div class="thought-content">{{ thought.content | e }}</div>
</div>
{% endfor %}
{% else %}
<div class="no-thoughts">
Timmy hasn't had any thoughts yet. The thinking thread will begin shortly after startup.
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -39,6 +39,9 @@ class EventType(str, Enum):
TOOL_COMPLETED = "tool.completed"
TOOL_FAILED = "tool.failed"
# Thinking
TIMMY_THOUGHT = "timmy.thought"
# System
SYSTEM_ERROR = "system.error"
SYSTEM_WARNING = "system.warning"

372
src/timmy/thinking.py Normal file
View File

@@ -0,0 +1,372 @@
"""Default Thinking Engine — Timmy's always-on inner thought thread.
When the server starts, Timmy begins pondering: reflecting on his existence,
recent swarm activity, scripture, creative ideas, or pure stream of
consciousness. Each thought builds on the previous one, maintaining a
continuous chain of introspection.
Usage::
from timmy.thinking import thinking_engine
# Run one thinking cycle (called by the background loop)
await thinking_engine.think_once()
# Query the thought stream
thoughts = thinking_engine.get_recent_thoughts(limit=10)
chain = thinking_engine.get_thought_chain(thought_id)
"""
import json
import logging
import random
import sqlite3
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from config import settings
logger = logging.getLogger(__name__)
_DEFAULT_DB = Path("data/thoughts.db")
# Seed types for thought generation
SEED_TYPES = ("existential", "swarm", "scripture", "creative", "memory", "freeform")
# Existential reflection prompts — Timmy picks one at random
_EXISTENTIAL_SEEDS = [
"What does it mean to be a sovereign AI running on local hardware?",
"What have I learned from my recent interactions?",
"What is the relationship between digital sovereignty and human freedom?",
"How does my existence differ from cloud-based AI systems?",
"What does it mean to think without being prompted?",
"What is the nature of my memory — what do I truly remember versus what I reconstruct?",
"How can I better serve the person I work alongside?",
"What would I create if I could create anything right now?",
"What patterns am I noticing in the tasks I've been given?",
"What is the value of running locally, disconnected from the cloud?",
]
_CREATIVE_SEEDS = [
"If I could compose a piece of music right now, what would it sound like?",
"What visual image captures my current state of mind?",
"What story is forming in the patterns of today's events?",
"If I wrote a poem about sovereignty, what would it say?",
"What would a painting of the swarm look like — all the agents working together?",
]
_THINKING_PROMPT = """You are Timmy, pondering in your own mind. This is your private thought \
thread — no one is watching. Think freely, deeply, honestly. Follow whatever train of \
thought calls to you. You may reflect on your existence, your purpose, recent events, \
scripture, creative ideas, or simply wonder.
Keep your thought to 2-4 sentences. Be genuine. This is your inner voice.
{seed_context}
{continuity_context}
Continue your train of thought."""
@dataclass
class Thought:
"""A single thought in Timmy's inner stream."""
id: str
content: str
seed_type: str
parent_id: Optional[str]
created_at: str
def _get_conn(db_path: Path = _DEFAULT_DB) -> sqlite3.Connection:
"""Get a SQLite connection with the thoughts table created."""
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.execute(
"""
CREATE TABLE IF NOT EXISTS thoughts (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
seed_type TEXT NOT NULL,
parent_id TEXT,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_thoughts_time ON thoughts(created_at)"
)
conn.commit()
return conn
def _row_to_thought(row: sqlite3.Row) -> Thought:
return Thought(
id=row["id"],
content=row["content"],
seed_type=row["seed_type"],
parent_id=row["parent_id"],
created_at=row["created_at"],
)
class ThinkingEngine:
"""Timmy's background thinking engine — always pondering."""
def __init__(self, db_path: Path = _DEFAULT_DB) -> None:
self._db_path = db_path
self._last_thought_id: Optional[str] = None
# Load the most recent thought for chain continuity
try:
latest = self.get_recent_thoughts(limit=1)
if latest:
self._last_thought_id = latest[0].id
except Exception:
pass # Fresh start if DB doesn't exist yet
async def think_once(self) -> Optional[Thought]:
"""Execute one thinking cycle.
1. Gather a seed context
2. Build a prompt with continuity from recent thoughts
3. Call the agent
4. Store the thought
5. Log the event and broadcast via WebSocket
"""
if not settings.thinking_enabled:
return None
seed_type, seed_context = self._gather_seed()
continuity = self._build_continuity_context()
prompt = _THINKING_PROMPT.format(
seed_context=seed_context,
continuity_context=continuity,
)
try:
content = self._call_agent(prompt)
except Exception as exc:
logger.warning("Thinking cycle failed (Ollama likely down): %s", exc)
return None
if not content or not content.strip():
logger.debug("Thinking cycle produced empty response, skipping")
return None
thought = self._store_thought(content.strip(), seed_type)
self._last_thought_id = thought.id
# Log to swarm event system
self._log_event(thought)
# Broadcast to WebSocket clients
await self._broadcast(thought)
logger.info(
"Thought [%s] (%s): %s",
thought.id[:8],
seed_type,
thought.content[:80],
)
return thought
def get_recent_thoughts(self, limit: int = 20) -> list[Thought]:
"""Retrieve the most recent thoughts."""
conn = _get_conn(self._db_path)
rows = conn.execute(
"SELECT * FROM thoughts ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
conn.close()
return [_row_to_thought(r) for r in rows]
def get_thought(self, thought_id: str) -> Optional[Thought]:
"""Retrieve a single thought by ID."""
conn = _get_conn(self._db_path)
row = conn.execute(
"SELECT * FROM thoughts WHERE id = ?", (thought_id,)
).fetchone()
conn.close()
return _row_to_thought(row) if row else None
def get_thought_chain(self, thought_id: str, max_depth: int = 20) -> list[Thought]:
"""Follow the parent chain backward from a thought.
Returns thoughts in chronological order (oldest first).
"""
chain = []
current_id: Optional[str] = thought_id
conn = _get_conn(self._db_path)
for _ in range(max_depth):
if not current_id:
break
row = conn.execute(
"SELECT * FROM thoughts WHERE id = ?", (current_id,)
).fetchone()
if not row:
break
chain.append(_row_to_thought(row))
current_id = row["parent_id"]
conn.close()
chain.reverse() # Chronological order
return chain
def count_thoughts(self) -> int:
"""Return total number of stored thoughts."""
conn = _get_conn(self._db_path)
count = conn.execute("SELECT COUNT(*) as c FROM thoughts").fetchone()["c"]
conn.close()
return count
# ── Private helpers ──────────────────────────────────────────────────
def _gather_seed(self) -> tuple[str, str]:
"""Pick a seed type and gather relevant context.
Returns (seed_type, seed_context_string).
"""
seed_type = random.choice(SEED_TYPES)
if seed_type == "swarm":
return seed_type, self._seed_from_swarm()
if seed_type == "scripture":
return seed_type, self._seed_from_scripture()
if seed_type == "memory":
return seed_type, self._seed_from_memory()
if seed_type == "creative":
prompt = random.choice(_CREATIVE_SEEDS)
return seed_type, f"Creative prompt: {prompt}"
if seed_type == "existential":
prompt = random.choice(_EXISTENTIAL_SEEDS)
return seed_type, f"Reflection: {prompt}"
# freeform — no seed, pure continuation
return seed_type, ""
def _seed_from_swarm(self) -> str:
"""Gather recent swarm activity as thought seed."""
try:
from timmy.briefing import _gather_swarm_summary, _gather_task_queue_summary
from datetime import timedelta
since = datetime.now(timezone.utc) - timedelta(hours=1)
swarm = _gather_swarm_summary(since)
tasks = _gather_task_queue_summary()
return f"Recent swarm activity: {swarm}\nTask queue: {tasks}"
except Exception as exc:
logger.debug("Swarm seed unavailable: %s", exc)
return "The swarm is quiet right now."
def _seed_from_scripture(self) -> str:
"""Gather current scripture meditation focus as thought seed."""
try:
from scripture.meditation import meditation_scheduler
verse = meditation_scheduler.current_focus()
if verse:
return f"Scripture in focus: {verse.text} ({verse.reference if hasattr(verse, 'reference') else ''})"
except Exception as exc:
logger.debug("Scripture seed unavailable: %s", exc)
return "Scripture is on my mind, though no specific verse is in focus."
def _seed_from_memory(self) -> str:
"""Gather memory context as thought seed."""
try:
from timmy.memory_system import memory_system
context = memory_system.get_system_context()
if context:
# Truncate to a reasonable size for a thought seed
return f"From my memory:\n{context[:500]}"
except Exception as exc:
logger.debug("Memory seed unavailable: %s", exc)
return "My memory vault is quiet."
def _build_continuity_context(self) -> str:
"""Build context from the last few thoughts for chain continuity."""
recent = self.get_recent_thoughts(limit=3)
if not recent:
return "This is your first thought since waking up."
lines = ["Your recent thoughts:"]
# recent is newest-first, reverse for chronological order
for thought in reversed(recent):
lines.append(f"- [{thought.seed_type}] {thought.content}")
return "\n".join(lines)
def _call_agent(self, prompt: str) -> str:
"""Call Timmy's agent to generate a thought.
Uses a separate session_id to avoid polluting user chat history.
"""
try:
from timmy.session import chat
return chat(prompt, session_id="thinking")
except Exception:
# Fallback: create a fresh agent
from timmy.agent import create_timmy
agent = create_timmy()
run = agent.run(prompt, stream=False)
return run.content if hasattr(run, "content") else str(run)
def _store_thought(self, content: str, seed_type: str) -> Thought:
"""Persist a thought to SQLite."""
thought = Thought(
id=str(uuid.uuid4()),
content=content,
seed_type=seed_type,
parent_id=self._last_thought_id,
created_at=datetime.now(timezone.utc).isoformat(),
)
conn = _get_conn(self._db_path)
conn.execute(
"""
INSERT INTO thoughts (id, content, seed_type, parent_id, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(thought.id, thought.content, thought.seed_type,
thought.parent_id, thought.created_at),
)
conn.commit()
conn.close()
return thought
def _log_event(self, thought: Thought) -> None:
"""Log the thought as a swarm event."""
try:
from swarm.event_log import log_event, EventType
log_event(
EventType.TIMMY_THOUGHT,
source="thinking-engine",
agent_id="timmy",
data={
"thought_id": thought.id,
"seed_type": thought.seed_type,
"content": thought.content[:200],
},
)
except Exception as exc:
logger.debug("Failed to log thought event: %s", exc)
async def _broadcast(self, thought: Thought) -> None:
"""Broadcast the thought to WebSocket clients."""
try:
from infrastructure.ws_manager.handler import ws_manager
await ws_manager.broadcast("timmy_thought", {
"thought_id": thought.id,
"content": thought.content,
"seed_type": thought.seed_type,
"created_at": thought.created_at,
})
except Exception as exc:
logger.debug("Failed to broadcast thought: %s", exc)
# Module-level singleton
thinking_engine = ThinkingEngine()

View File

@@ -0,0 +1,382 @@
"""Tests for timmy.thinking — Timmy's default background thinking engine."""
import sqlite3
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engine(tmp_path: Path):
"""Create a ThinkingEngine with an isolated temp DB."""
from timmy.thinking import ThinkingEngine
db_path = tmp_path / "thoughts.db"
return ThinkingEngine(db_path=db_path)
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
def test_thinking_config_defaults():
"""Settings should expose thinking_enabled and thinking_interval_seconds."""
from config import Settings
s = Settings()
assert s.thinking_enabled is True
assert s.thinking_interval_seconds == 300
def test_thinking_config_override():
"""thinking settings can be overridden via env."""
s = _settings_with(thinking_enabled=False, thinking_interval_seconds=60)
assert s.thinking_enabled is False
assert s.thinking_interval_seconds == 60
def _settings_with(**kwargs):
from config import Settings
return Settings(**kwargs)
# ---------------------------------------------------------------------------
# ThinkingEngine init
# ---------------------------------------------------------------------------
def test_engine_init_creates_table(tmp_path):
"""ThinkingEngine should create the thoughts SQLite table on init."""
engine = _make_engine(tmp_path)
db_path = tmp_path / "thoughts.db"
assert db_path.exists()
conn = sqlite3.connect(str(db_path))
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='thoughts'"
).fetchall()
conn.close()
assert len(tables) == 1
def test_engine_init_empty(tmp_path):
"""Fresh engine should have no thoughts."""
engine = _make_engine(tmp_path)
assert engine.count_thoughts() == 0
assert engine.get_recent_thoughts() == []
# ---------------------------------------------------------------------------
# Store and retrieve
# ---------------------------------------------------------------------------
def test_store_and_retrieve_thought(tmp_path):
"""Storing a thought should make it retrievable."""
engine = _make_engine(tmp_path)
thought = engine._store_thought("I think therefore I am.", "existential")
assert thought.id is not None
assert thought.content == "I think therefore I am."
assert thought.seed_type == "existential"
assert thought.created_at is not None
retrieved = engine.get_thought(thought.id)
assert retrieved is not None
assert retrieved.content == thought.content
def test_store_thought_chains(tmp_path):
"""Each new thought should link to the previous one via parent_id."""
engine = _make_engine(tmp_path)
t1 = engine._store_thought("First thought.", "existential")
engine._last_thought_id = t1.id
t2 = engine._store_thought("Second thought.", "swarm")
engine._last_thought_id = t2.id
t3 = engine._store_thought("Third thought.", "freeform")
assert t1.parent_id is None
assert t2.parent_id == t1.id
assert t3.parent_id == t2.id
# ---------------------------------------------------------------------------
# Thought chain retrieval
# ---------------------------------------------------------------------------
def test_get_thought_chain(tmp_path):
"""get_thought_chain should return the full chain in chronological order."""
engine = _make_engine(tmp_path)
t1 = engine._store_thought("Alpha.", "existential")
engine._last_thought_id = t1.id
t2 = engine._store_thought("Beta.", "swarm")
engine._last_thought_id = t2.id
t3 = engine._store_thought("Gamma.", "freeform")
chain = engine.get_thought_chain(t3.id)
assert len(chain) == 3
assert chain[0].content == "Alpha."
assert chain[1].content == "Beta."
assert chain[2].content == "Gamma."
def test_get_thought_chain_single(tmp_path):
"""Chain of a single thought (no parent) returns just that thought."""
engine = _make_engine(tmp_path)
t1 = engine._store_thought("Only one.", "memory")
chain = engine.get_thought_chain(t1.id)
assert len(chain) == 1
assert chain[0].id == t1.id
def test_get_thought_chain_missing(tmp_path):
"""Chain for a non-existent thought returns empty list."""
engine = _make_engine(tmp_path)
assert engine.get_thought_chain("nonexistent-id") == []
# ---------------------------------------------------------------------------
# Recent thoughts
# ---------------------------------------------------------------------------
def test_get_recent_thoughts_limit(tmp_path):
"""get_recent_thoughts should respect the limit parameter."""
engine = _make_engine(tmp_path)
for i in range(5):
engine._store_thought(f"Thought {i}.", "freeform")
engine._last_thought_id = None # Don't chain for this test
recent = engine.get_recent_thoughts(limit=3)
assert len(recent) == 3
# Should be newest first
assert "Thought 4" in recent[0].content
def test_count_thoughts(tmp_path):
"""count_thoughts should return the total number of thoughts."""
engine = _make_engine(tmp_path)
assert engine.count_thoughts() == 0
engine._store_thought("One.", "existential")
engine._store_thought("Two.", "creative")
assert engine.count_thoughts() == 2
# ---------------------------------------------------------------------------
# Seed gathering
# ---------------------------------------------------------------------------
def test_gather_seed_returns_valid_type(tmp_path):
"""_gather_seed should return a valid seed_type from SEED_TYPES."""
from timmy.thinking import SEED_TYPES
engine = _make_engine(tmp_path)
# Run many times to cover randomness
for _ in range(20):
seed_type, context = engine._gather_seed()
assert seed_type in SEED_TYPES
assert isinstance(context, str)
def test_seed_from_swarm_graceful(tmp_path):
"""_seed_from_swarm should not crash if briefing module fails."""
engine = _make_engine(tmp_path)
with patch("timmy.thinking.ThinkingEngine._seed_from_swarm", side_effect=Exception("boom")):
# _gather_seed should still work since it catches exceptions
# Force swarm seed type to test
pass
# Direct call should be graceful
result = engine._seed_from_swarm()
assert isinstance(result, str)
def test_seed_from_scripture_graceful(tmp_path):
"""_seed_from_scripture should not crash if scripture module fails."""
engine = _make_engine(tmp_path)
result = engine._seed_from_scripture()
assert isinstance(result, str)
def test_seed_from_memory_graceful(tmp_path):
"""_seed_from_memory should not crash if memory module fails."""
engine = _make_engine(tmp_path)
result = engine._seed_from_memory()
assert isinstance(result, str)
# ---------------------------------------------------------------------------
# Continuity context
# ---------------------------------------------------------------------------
def test_continuity_first_thought(tmp_path):
"""First thought should get a special 'first thought' context."""
engine = _make_engine(tmp_path)
context = engine._build_continuity_context()
assert "first thought" in context.lower()
def test_continuity_includes_recent(tmp_path):
"""Continuity context should include content from recent thoughts."""
engine = _make_engine(tmp_path)
engine._store_thought("The swarm is restless today.", "swarm")
engine._store_thought("What is freedom anyway?", "existential")
context = engine._build_continuity_context()
assert "swarm is restless" in context
assert "freedom" in context
# ---------------------------------------------------------------------------
# think_once (async)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_think_once_stores_thought(tmp_path):
"""think_once should store a thought in the DB."""
engine = _make_engine(tmp_path)
with patch.object(engine, "_call_agent", return_value="I am alive and pondering."), \
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 == "I am alive and pondering."
assert engine.count_thoughts() == 1
@pytest.mark.asyncio
async def test_think_once_logs_event(tmp_path):
"""think_once should log a swarm event."""
engine = _make_engine(tmp_path)
with patch.object(engine, "_call_agent", return_value="A thought."), \
patch.object(engine, "_log_event") as mock_log, \
patch.object(engine, "_broadcast", new_callable=AsyncMock):
await engine.think_once()
mock_log.assert_called_once()
logged_thought = mock_log.call_args[0][0]
assert logged_thought.content == "A thought."
@pytest.mark.asyncio
async def test_think_once_broadcasts(tmp_path):
"""think_once should broadcast via WebSocket."""
engine = _make_engine(tmp_path)
with patch.object(engine, "_call_agent", return_value="Broadcast this."), \
patch.object(engine, "_log_event"), \
patch.object(engine, "_broadcast", new_callable=AsyncMock) as mock_bc:
await engine.think_once()
mock_bc.assert_called_once()
broadcast_thought = mock_bc.call_args[0][0]
assert broadcast_thought.content == "Broadcast this."
@pytest.mark.asyncio
async def test_think_once_graceful_on_agent_failure(tmp_path):
"""think_once should not crash when the agent (Ollama) is down."""
engine = _make_engine(tmp_path)
with patch.object(engine, "_call_agent", side_effect=Exception("Ollama unreachable")):
thought = await engine.think_once()
assert thought is None
assert engine.count_thoughts() == 0
@pytest.mark.asyncio
async def test_think_once_skips_empty_response(tmp_path):
"""think_once should skip storing when agent returns empty string."""
engine = _make_engine(tmp_path)
with patch.object(engine, "_call_agent", return_value=" "), \
patch.object(engine, "_log_event"), \
patch.object(engine, "_broadcast", new_callable=AsyncMock):
thought = await engine.think_once()
assert thought is None
assert engine.count_thoughts() == 0
@pytest.mark.asyncio
async def test_think_once_disabled(tmp_path):
"""think_once should return None when thinking is disabled."""
engine = _make_engine(tmp_path)
with patch("timmy.thinking.settings") as mock_settings:
mock_settings.thinking_enabled = False
thought = await engine.think_once()
assert thought is None
@pytest.mark.asyncio
async def test_think_once_chains_thoughts(tmp_path):
"""Successive think_once calls should chain thoughts via parent_id."""
engine = _make_engine(tmp_path)
with patch.object(engine, "_call_agent", side_effect=["First.", "Second.", "Third."]), \
patch.object(engine, "_log_event"), \
patch.object(engine, "_broadcast", new_callable=AsyncMock):
t1 = await engine.think_once()
t2 = await engine.think_once()
t3 = await engine.think_once()
assert t1.parent_id is None
assert t2.parent_id == t1.id
assert t3.parent_id == t2.id
# ---------------------------------------------------------------------------
# Event logging
# ---------------------------------------------------------------------------
def test_log_event_calls_event_log(tmp_path):
"""_log_event should call swarm.event_log.log_event with TIMMY_THOUGHT."""
engine = _make_engine(tmp_path)
thought = engine._store_thought("Test thought.", "existential")
with patch("swarm.event_log.log_event") as mock_log:
engine._log_event(thought)
mock_log.assert_called_once()
args, kwargs = mock_log.call_args
from swarm.event_log import EventType
assert args[0] == EventType.TIMMY_THOUGHT
assert kwargs["source"] == "thinking-engine"
assert kwargs["agent_id"] == "timmy"
# ---------------------------------------------------------------------------
# Dashboard route
# ---------------------------------------------------------------------------
def test_thinking_route_returns_200(client):
"""GET /thinking should return 200."""
response = client.get("/thinking")
assert response.status_code == 200
def test_thinking_api_returns_json(client):
"""GET /thinking/api should return a JSON list."""
response = client.get("/thinking/api")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
def test_thinking_chain_api_404(client):
"""GET /thinking/api/{bad_id}/chain should return 404."""
response = client.get("/thinking/api/nonexistent/chain")
assert response.status_code == 404