forked from Rockachopa/Timmy-time-dashboard
Compare commits
6 Commits
fix/router
...
kimi/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf57da87b3 | ||
| a3f61c67d3 | |||
| 32dbdc68c8 | |||
| 84302aedac | |||
| 2c217104db | |||
| 7452e8a4f0 |
@@ -38,6 +38,56 @@ def get_later_tasks(db: Session) -> list[Task]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_mit_tasks(db: Session, titles: list[str | None]) -> list[int]:
|
||||||
|
"""Create MIT tasks from a list of titles, return their IDs."""
|
||||||
|
task_ids: list[int] = []
|
||||||
|
for title in titles:
|
||||||
|
if title:
|
||||||
|
task = Task(
|
||||||
|
title=title,
|
||||||
|
is_mit=True,
|
||||||
|
state=TaskState.LATER,
|
||||||
|
certainty=TaskCertainty.SOFT,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
task_ids.append(task.id)
|
||||||
|
return task_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _create_other_tasks(db: Session, other_tasks: str):
|
||||||
|
"""Create non-MIT tasks from newline-separated text."""
|
||||||
|
for line in other_tasks.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
task = Task(
|
||||||
|
title=line,
|
||||||
|
state=TaskState.LATER,
|
||||||
|
certainty=TaskCertainty.FUZZY,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_now_next(db: Session):
|
||||||
|
"""Set initial NOW/NEXT states when both slots are empty."""
|
||||||
|
if get_now_task(db) or get_next_task(db):
|
||||||
|
return
|
||||||
|
later_tasks = (
|
||||||
|
db.query(Task)
|
||||||
|
.filter(Task.state == TaskState.LATER)
|
||||||
|
.order_by(Task.is_mit.desc(), Task.sort_order)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if later_tasks:
|
||||||
|
later_tasks[0].state = TaskState.NOW
|
||||||
|
db.add(later_tasks[0])
|
||||||
|
db.flush()
|
||||||
|
if len(later_tasks) > 1:
|
||||||
|
later_tasks[1].state = TaskState.NEXT
|
||||||
|
db.add(later_tasks[1])
|
||||||
|
|
||||||
|
|
||||||
def promote_tasks(db: Session):
|
def promote_tasks(db: Session):
|
||||||
"""Enforce the NOW/NEXT/LATER state machine invariants.
|
"""Enforce the NOW/NEXT/LATER state machine invariants.
|
||||||
|
|
||||||
@@ -114,63 +164,19 @@ async def post_morning_ritual(
|
|||||||
other_tasks: str = Form(""),
|
other_tasks: str = Form(""),
|
||||||
):
|
):
|
||||||
"""Process morning ritual: create MITs, other tasks, and set initial states."""
|
"""Process morning ritual: create MITs, other tasks, and set initial states."""
|
||||||
# Create Journal Entry
|
|
||||||
mit_task_ids = []
|
|
||||||
journal_entry = JournalEntry(entry_date=date.today())
|
journal_entry = JournalEntry(entry_date=date.today())
|
||||||
db.add(journal_entry)
|
db.add(journal_entry)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(journal_entry)
|
db.refresh(journal_entry)
|
||||||
|
|
||||||
# Create MIT tasks
|
journal_entry.mit_task_ids = _create_mit_tasks(db, [mit1_title, mit2_title, mit3_title])
|
||||||
for mit_title in [mit1_title, mit2_title, mit3_title]:
|
|
||||||
if mit_title:
|
|
||||||
task = Task(
|
|
||||||
title=mit_title,
|
|
||||||
is_mit=True,
|
|
||||||
state=TaskState.LATER, # Initially LATER, will be promoted
|
|
||||||
certainty=TaskCertainty.SOFT,
|
|
||||||
)
|
|
||||||
db.add(task)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(task)
|
|
||||||
mit_task_ids.append(task.id)
|
|
||||||
|
|
||||||
journal_entry.mit_task_ids = mit_task_ids
|
|
||||||
db.add(journal_entry)
|
db.add(journal_entry)
|
||||||
|
|
||||||
# Create other tasks
|
_create_other_tasks(db, other_tasks)
|
||||||
for task_title in other_tasks.split("\n"):
|
|
||||||
task_title = task_title.strip()
|
|
||||||
if task_title:
|
|
||||||
task = Task(
|
|
||||||
title=task_title,
|
|
||||||
state=TaskState.LATER,
|
|
||||||
certainty=TaskCertainty.FUZZY,
|
|
||||||
)
|
|
||||||
db.add(task)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Set initial NOW/NEXT states
|
_seed_now_next(db)
|
||||||
# Set initial NOW/NEXT states after all tasks are created
|
db.commit()
|
||||||
if not get_now_task(db) and not get_next_task(db):
|
|
||||||
later_tasks = (
|
|
||||||
db.query(Task)
|
|
||||||
.filter(Task.state == TaskState.LATER)
|
|
||||||
.order_by(Task.is_mit.desc(), Task.sort_order)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
if later_tasks:
|
|
||||||
# Set the highest priority LATER task to NOW
|
|
||||||
later_tasks[0].state = TaskState.NOW
|
|
||||||
db.add(later_tasks[0])
|
|
||||||
db.flush() # Flush to make the change visible for the next query
|
|
||||||
|
|
||||||
# Set the next highest priority LATER task to NEXT
|
|
||||||
if len(later_tasks) > 1:
|
|
||||||
later_tasks[1].state = TaskState.NEXT
|
|
||||||
db.add(later_tasks[1])
|
|
||||||
db.commit() # Commit changes after initial NOW/NEXT setup
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -138,6 +138,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Spark Intelligence -->
|
||||||
|
{% from "macros.html" import panel %}
|
||||||
|
<div class="mc-card-spaced">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Spark Intelligence</h2>
|
||||||
|
<div>
|
||||||
|
<span class="badge" id="spark-status-badge">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-3">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value" id="spark-events">-</div>
|
||||||
|
<div class="stat-label">Events</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value" id="spark-memories">-</div>
|
||||||
|
<div class="stat-label">Memories</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value" id="spark-predictions">-</div>
|
||||||
|
<div class="stat-label">Predictions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-2 mc-section-gap">
|
||||||
|
{% call panel("SPARK TIMELINE", id="spark-timeline-panel",
|
||||||
|
hx_get="/spark/timeline",
|
||||||
|
hx_trigger="load, every 10s") %}
|
||||||
|
<div class="spark-timeline-scroll">
|
||||||
|
<p class="chat-history-placeholder">Loading timeline...</p>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% call panel("SPARK INSIGHTS", id="spark-insights-panel",
|
||||||
|
hx_get="/spark/insights",
|
||||||
|
hx_trigger="load, every 30s") %}
|
||||||
|
<p class="chat-history-placeholder">Loading insights...</p>
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Chat History -->
|
<!-- Chat History -->
|
||||||
<div class="card mc-card-spaced">
|
<div class="card mc-card-spaced">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -428,7 +469,34 @@ async function loadGrokStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load Spark status
|
||||||
|
async function loadSparkStatus() {
|
||||||
|
try {
|
||||||
|
var response = await fetch('/spark');
|
||||||
|
var data = await response.json();
|
||||||
|
var st = data.status || {};
|
||||||
|
|
||||||
|
document.getElementById('spark-events').textContent = st.total_events || 0;
|
||||||
|
document.getElementById('spark-memories').textContent = st.total_memories || 0;
|
||||||
|
document.getElementById('spark-predictions').textContent = st.total_predictions || 0;
|
||||||
|
|
||||||
|
var badge = document.getElementById('spark-status-badge');
|
||||||
|
if (st.total_events > 0) {
|
||||||
|
badge.textContent = 'Active';
|
||||||
|
badge.className = 'badge badge-success';
|
||||||
|
} else {
|
||||||
|
badge.textContent = 'Idle';
|
||||||
|
badge.className = 'badge badge-warning';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
var badge = document.getElementById('spark-status-badge');
|
||||||
|
badge.textContent = 'Offline';
|
||||||
|
badge.className = 'badge badge-danger';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
|
loadSparkStatus();
|
||||||
loadSovereignty();
|
loadSovereignty();
|
||||||
loadHealth();
|
loadHealth();
|
||||||
loadSwarmStats();
|
loadSwarmStats();
|
||||||
@@ -442,5 +510,6 @@ setInterval(loadHealth, 10000);
|
|||||||
setInterval(loadSwarmStats, 5000);
|
setInterval(loadSwarmStats, 5000);
|
||||||
setInterval(updateHeartbeat, 5000);
|
setInterval(updateHeartbeat, 5000);
|
||||||
setInterval(loadGrokStats, 10000);
|
setInterval(loadGrokStats, 10000);
|
||||||
|
setInterval(loadSparkStatus, 15000);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -564,6 +564,7 @@ class CascadeRouter:
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
model=model or provider.get_default_model(),
|
model=model or provider.get_default_model(),
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
)
|
)
|
||||||
elif provider.type == "openai":
|
elif provider.type == "openai":
|
||||||
@@ -604,6 +605,7 @@ class CascadeRouter:
|
|||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
model: str,
|
model: str,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
|
max_tokens: int | None = None,
|
||||||
content_type: ContentType = ContentType.TEXT,
|
content_type: ContentType = ContentType.TEXT,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Call Ollama API with multi-modal support."""
|
"""Call Ollama API with multi-modal support."""
|
||||||
@@ -614,13 +616,15 @@ class CascadeRouter:
|
|||||||
# Transform messages for Ollama format (including images)
|
# Transform messages for Ollama format (including images)
|
||||||
transformed_messages = self._transform_messages_for_ollama(messages)
|
transformed_messages = self._transform_messages_for_ollama(messages)
|
||||||
|
|
||||||
|
options = {"temperature": temperature}
|
||||||
|
if max_tokens:
|
||||||
|
options["num_predict"] = max_tokens
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": transformed_messages,
|
"messages": transformed_messages,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {
|
"options": options,
|
||||||
"temperature": temperature,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout = aiohttp.ClientTimeout(total=self.config.timeout_seconds)
|
timeout = aiohttp.ClientTimeout(total=self.config.timeout_seconds)
|
||||||
|
|||||||
@@ -174,15 +174,8 @@ class ConversationManager:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def should_use_tools(self, message: str, context: ConversationContext) -> bool:
|
_TOOL_KEYWORDS = frozenset(
|
||||||
"""Determine if this message likely requires tools.
|
{
|
||||||
|
|
||||||
Returns True if tools are likely needed, False for simple chat.
|
|
||||||
"""
|
|
||||||
message_lower = message.lower().strip()
|
|
||||||
|
|
||||||
# Tool keywords that suggest tool usage is needed
|
|
||||||
tool_keywords = [
|
|
||||||
"search",
|
"search",
|
||||||
"look up",
|
"look up",
|
||||||
"find",
|
"find",
|
||||||
@@ -203,10 +196,11 @@ class ConversationManager:
|
|||||||
"shell",
|
"shell",
|
||||||
"command",
|
"command",
|
||||||
"install",
|
"install",
|
||||||
]
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Chat-only keywords that definitely don't need tools
|
_CHAT_ONLY_KEYWORDS = frozenset(
|
||||||
chat_only = [
|
{
|
||||||
"hello",
|
"hello",
|
||||||
"hi ",
|
"hi ",
|
||||||
"hey",
|
"hey",
|
||||||
@@ -221,30 +215,47 @@ class ConversationManager:
|
|||||||
"goodbye",
|
"goodbye",
|
||||||
"tell me about yourself",
|
"tell me about yourself",
|
||||||
"what can you do",
|
"what can you do",
|
||||||
]
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Check for chat-only patterns first
|
_SIMPLE_QUESTION_PREFIXES = ("what is", "who is", "how does", "why is", "when did", "where is")
|
||||||
for pattern in chat_only:
|
_TIME_WORDS = ("today", "now", "current", "latest", "this week", "this month")
|
||||||
if pattern in message_lower:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check for tool keywords
|
def _is_chat_only(self, message_lower: str) -> bool:
|
||||||
for keyword in tool_keywords:
|
"""Return True if the message matches a chat-only pattern."""
|
||||||
if keyword in message_lower:
|
return any(kw in message_lower for kw in self._CHAT_ONLY_KEYWORDS)
|
||||||
return True
|
|
||||||
|
|
||||||
# Simple questions (starting with what, who, how, why, when, where)
|
def _has_tool_keyword(self, message_lower: str) -> bool:
|
||||||
# usually don't need tools unless about current/real-time info
|
"""Return True if the message contains a tool-related keyword."""
|
||||||
simple_question_words = ["what is", "who is", "how does", "why is", "when did", "where is"]
|
return any(kw in message_lower for kw in self._TOOL_KEYWORDS)
|
||||||
for word in simple_question_words:
|
|
||||||
if message_lower.startswith(word):
|
def _is_simple_question(self, message_lower: str) -> bool | None:
|
||||||
# Check if it's asking about current/real-time info
|
"""Check if message is a simple question.
|
||||||
time_words = ["today", "now", "current", "latest", "this week", "this month"]
|
|
||||||
if any(t in message_lower for t in time_words):
|
Returns True if it needs tools (real-time info), False if it
|
||||||
return True
|
doesn't, or None if the message isn't a simple question.
|
||||||
return False
|
"""
|
||||||
|
for prefix in self._SIMPLE_QUESTION_PREFIXES:
|
||||||
|
if message_lower.startswith(prefix):
|
||||||
|
return any(t in message_lower for t in self._TIME_WORDS)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def should_use_tools(self, message: str, context: ConversationContext) -> bool:
|
||||||
|
"""Determine if this message likely requires tools.
|
||||||
|
|
||||||
|
Returns True if tools are likely needed, False for simple chat.
|
||||||
|
"""
|
||||||
|
message_lower = message.lower().strip()
|
||||||
|
|
||||||
|
if self._is_chat_only(message_lower):
|
||||||
|
return False
|
||||||
|
if self._has_tool_keyword(message_lower):
|
||||||
|
return True
|
||||||
|
|
||||||
|
simple = self._is_simple_question(message_lower)
|
||||||
|
if simple is not None:
|
||||||
|
return simple
|
||||||
|
|
||||||
# Default: don't use tools for unclear cases
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,52 +89,41 @@ def list_swarm_agents() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def delegate_to_kimi(task: str, working_directory: str = "") -> dict[str, Any]:
|
def _find_kimi_cli() -> str | None:
|
||||||
"""Delegate a coding task to Kimi, the external coding agent.
|
"""Return the path to the kimi CLI, or None if not installed."""
|
||||||
|
|
||||||
Kimi has 262K context and is optimized for code tasks: writing,
|
|
||||||
debugging, refactoring, test writing. Timmy thinks and plans,
|
|
||||||
Kimi executes bulk code changes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task: Clear, specific coding task description. Include file paths
|
|
||||||
and expected behavior. Good: "Fix the bug in src/timmy/session.py
|
|
||||||
where sessions don't persist." Bad: "Fix all bugs."
|
|
||||||
working_directory: Directory for Kimi to work in. Defaults to repo root.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with success status and Kimi's output or error.
|
|
||||||
"""
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
|
return shutil.which("kimi")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_workdir(working_directory: str) -> str | dict[str, Any]:
|
||||||
|
"""Resolve and validate the working directory.
|
||||||
|
|
||||||
|
Returns the resolved path string, or an error dict if invalid.
|
||||||
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
kimi_path = shutil.which("kimi")
|
|
||||||
if not kimi_path:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": "kimi CLI not found on PATH. Install with: pip install kimi-cli",
|
|
||||||
}
|
|
||||||
|
|
||||||
workdir = working_directory or settings.repo_root
|
workdir = working_directory or settings.repo_root
|
||||||
if not Path(workdir).is_dir():
|
if not Path(workdir).is_dir():
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": f"Working directory does not exist: {workdir}",
|
"error": f"Working directory does not exist: {workdir}",
|
||||||
}
|
}
|
||||||
|
return workdir
|
||||||
|
|
||||||
cmd = [kimi_path, "--print", "-p", task]
|
|
||||||
|
|
||||||
logger.info("Delegating to Kimi: %s (cwd=%s)", task[:80], workdir)
|
def _run_kimi(cmd: list[str], workdir: str) -> dict[str, Any]:
|
||||||
|
"""Execute the kimi subprocess and return a result dict."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=300, # 5 minute timeout for coding tasks
|
timeout=300,
|
||||||
cwd=workdir,
|
cwd=workdir,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -157,3 +146,34 @@ def delegate_to_kimi(task: str, working_directory: str = "") -> dict[str, Any]:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"error": f"Failed to run Kimi: {exc}",
|
"error": f"Failed to run Kimi: {exc}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def delegate_to_kimi(task: str, working_directory: str = "") -> dict[str, Any]:
|
||||||
|
"""Delegate a coding task to Kimi, the external coding agent.
|
||||||
|
|
||||||
|
Kimi has 262K context and is optimized for code tasks: writing,
|
||||||
|
debugging, refactoring, test writing. Timmy thinks and plans,
|
||||||
|
Kimi executes bulk code changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Clear, specific coding task description. Include file paths
|
||||||
|
and expected behavior. Good: "Fix the bug in src/timmy/session.py
|
||||||
|
where sessions don't persist." Bad: "Fix all bugs."
|
||||||
|
working_directory: Directory for Kimi to work in. Defaults to repo root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status and Kimi's output or error.
|
||||||
|
"""
|
||||||
|
kimi_path = _find_kimi_cli()
|
||||||
|
if not kimi_path:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "kimi CLI not found on PATH. Install with: pip install kimi-cli",
|
||||||
|
}
|
||||||
|
|
||||||
|
workdir = _resolve_workdir(working_directory)
|
||||||
|
if isinstance(workdir, dict):
|
||||||
|
return workdir
|
||||||
|
|
||||||
|
logger.info("Delegating to Kimi: %s (cwd=%s)", task[:80], workdir)
|
||||||
|
return _run_kimi([kimi_path, "--print", "-p", task], workdir)
|
||||||
|
|||||||
187
tests/dashboard/test_tower.py
Normal file
187
tests/dashboard/test_tower.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""Tests for Tower dashboard route (/tower)."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_spark_engine():
|
||||||
|
"""Return a mock spark_engine with realistic return values."""
|
||||||
|
engine = MagicMock()
|
||||||
|
|
||||||
|
engine.status.return_value = {
|
||||||
|
"enabled": True,
|
||||||
|
"events_captured": 5,
|
||||||
|
"memories_stored": 3,
|
||||||
|
"predictions": {"total": 2, "avg_accuracy": 0.85},
|
||||||
|
"event_types": {
|
||||||
|
"task_posted": 2,
|
||||||
|
"bid_submitted": 1,
|
||||||
|
"task_assigned": 1,
|
||||||
|
"task_completed": 1,
|
||||||
|
"task_failed": 0,
|
||||||
|
"agent_joined": 0,
|
||||||
|
"tool_executed": 0,
|
||||||
|
"creative_step": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
event = MagicMock()
|
||||||
|
event.event_type = "task_completed"
|
||||||
|
event.description = "Task finished"
|
||||||
|
event.importance = 0.8
|
||||||
|
event.created_at = "2026-01-01T00:00:00"
|
||||||
|
event.agent_id = "agent-1234-abcd"
|
||||||
|
event.task_id = "task-5678-efgh"
|
||||||
|
event.data = '{"result": "ok"}'
|
||||||
|
engine.get_timeline.return_value = [event]
|
||||||
|
|
||||||
|
pred = MagicMock()
|
||||||
|
pred.task_id = "task-5678-efgh"
|
||||||
|
pred.accuracy = 0.9
|
||||||
|
pred.evaluated_at = "2026-01-01T01:00:00"
|
||||||
|
pred.created_at = "2026-01-01T00:30:00"
|
||||||
|
pred.predicted_value = '{"outcome": "success"}'
|
||||||
|
engine.get_predictions.return_value = [pred]
|
||||||
|
|
||||||
|
advisory = MagicMock()
|
||||||
|
advisory.category = "performance"
|
||||||
|
advisory.priority = "high"
|
||||||
|
advisory.title = "Slow tasks"
|
||||||
|
advisory.detail = "Tasks taking longer than expected"
|
||||||
|
advisory.suggested_action = "Scale up workers"
|
||||||
|
engine.get_advisories.return_value = [advisory]
|
||||||
|
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
class TestTowerUI:
|
||||||
|
"""Tests for GET /tower endpoint."""
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_tower_returns_200(self, mock_engine, client):
|
||||||
|
response = client.get("/tower")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_tower_returns_html(self, mock_engine, client):
|
||||||
|
response = client.get("/tower")
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_tower_contains_dashboard_content(self, mock_engine, client):
|
||||||
|
response = client.get("/tower")
|
||||||
|
body = response.text
|
||||||
|
assert "tower" in body.lower() or "spark" in body.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSparkSnapshot:
|
||||||
|
"""Tests for _spark_snapshot helper."""
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_snapshot_structure(self, mock_engine):
|
||||||
|
from dashboard.routes.tower import _spark_snapshot
|
||||||
|
|
||||||
|
snap = _spark_snapshot()
|
||||||
|
assert snap["type"] == "spark_state"
|
||||||
|
assert "status" in snap
|
||||||
|
assert "events" in snap
|
||||||
|
assert "predictions" in snap
|
||||||
|
assert "advisories" in snap
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_snapshot_events_parsed(self, mock_engine):
|
||||||
|
from dashboard.routes.tower import _spark_snapshot
|
||||||
|
|
||||||
|
snap = _spark_snapshot()
|
||||||
|
ev = snap["events"][0]
|
||||||
|
assert ev["event_type"] == "task_completed"
|
||||||
|
assert ev["importance"] == 0.8
|
||||||
|
assert ev["agent_id"] == "agent-12"
|
||||||
|
assert ev["task_id"] == "task-567"
|
||||||
|
assert ev["data"] == {"result": "ok"}
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_snapshot_predictions_parsed(self, mock_engine):
|
||||||
|
from dashboard.routes.tower import _spark_snapshot
|
||||||
|
|
||||||
|
snap = _spark_snapshot()
|
||||||
|
pred = snap["predictions"][0]
|
||||||
|
assert pred["task_id"] == "task-567"
|
||||||
|
assert pred["accuracy"] == 0.9
|
||||||
|
assert pred["evaluated"] is True
|
||||||
|
assert pred["predicted"] == {"outcome": "success"}
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_snapshot_advisories_parsed(self, mock_engine):
|
||||||
|
from dashboard.routes.tower import _spark_snapshot
|
||||||
|
|
||||||
|
snap = _spark_snapshot()
|
||||||
|
adv = snap["advisories"][0]
|
||||||
|
assert adv["category"] == "performance"
|
||||||
|
assert adv["priority"] == "high"
|
||||||
|
assert adv["title"] == "Slow tasks"
|
||||||
|
assert adv["suggested_action"] == "Scale up workers"
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine")
|
||||||
|
def test_snapshot_handles_empty_state(self, mock_engine):
|
||||||
|
mock_engine.status.return_value = {"enabled": False}
|
||||||
|
mock_engine.get_timeline.return_value = []
|
||||||
|
mock_engine.get_predictions.return_value = []
|
||||||
|
mock_engine.get_advisories.return_value = []
|
||||||
|
|
||||||
|
from dashboard.routes.tower import _spark_snapshot
|
||||||
|
|
||||||
|
snap = _spark_snapshot()
|
||||||
|
assert snap["events"] == []
|
||||||
|
assert snap["predictions"] == []
|
||||||
|
assert snap["advisories"] == []
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine")
|
||||||
|
def test_snapshot_handles_invalid_json_data(self, mock_engine):
|
||||||
|
mock_engine.status.return_value = {"enabled": True}
|
||||||
|
|
||||||
|
event = MagicMock()
|
||||||
|
event.event_type = "test"
|
||||||
|
event.description = "bad data"
|
||||||
|
event.importance = 0.5
|
||||||
|
event.created_at = "2026-01-01T00:00:00"
|
||||||
|
event.agent_id = None
|
||||||
|
event.task_id = None
|
||||||
|
event.data = "not-json{"
|
||||||
|
mock_engine.get_timeline.return_value = [event]
|
||||||
|
|
||||||
|
pred = MagicMock()
|
||||||
|
pred.task_id = None
|
||||||
|
pred.accuracy = None
|
||||||
|
pred.evaluated_at = None
|
||||||
|
pred.created_at = "2026-01-01T00:00:00"
|
||||||
|
pred.predicted_value = None
|
||||||
|
mock_engine.get_predictions.return_value = [pred]
|
||||||
|
|
||||||
|
mock_engine.get_advisories.return_value = []
|
||||||
|
|
||||||
|
from dashboard.routes.tower import _spark_snapshot
|
||||||
|
|
||||||
|
snap = _spark_snapshot()
|
||||||
|
ev = snap["events"][0]
|
||||||
|
assert ev["data"] == {}
|
||||||
|
assert "agent_id" not in ev
|
||||||
|
assert "task_id" not in ev
|
||||||
|
|
||||||
|
pred = snap["predictions"][0]
|
||||||
|
assert pred["task_id"] == "?"
|
||||||
|
assert pred["predicted"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestTowerWebSocket:
|
||||||
|
"""Tests for WS /tower/ws endpoint."""
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
@patch("dashboard.routes.tower._PUSH_INTERVAL", 0)
|
||||||
|
def test_ws_sends_initial_snapshot(self, mock_engine, client):
|
||||||
|
import json
|
||||||
|
|
||||||
|
with client.websocket_connect("/tower/ws") as ws:
|
||||||
|
data = json.loads(ws.receive_text())
|
||||||
|
assert data["type"] == "spark_state"
|
||||||
|
assert "status" in data
|
||||||
|
assert "events" in data
|
||||||
Reference in New Issue
Block a user